Files
cowork-local/ui/flow_dialog.py
T
anhtnm1andClaude Opus 5 e29a0ccdbd refactor: vá 4 hồi quy, tách 4 file chạm trần LOC, docstring lên 100%
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>
2026-08-30 10:41:45 +09:00

668 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Flow builder dialog — design a Req→Demo pipeline and run it.
The user adds ordered steps, attaches a skill / AI agent / hint to each step,
saves the result as a reusable template, and runs the whole flow. Running a flow
enqueues each step as a Code-agent turn (executed sequentially).
"""
from __future__ import annotations
import copy
from typing import List, Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QCheckBox, QComboBox, QDialog, QFileDialog, QFormLayout, QHBoxLayout,
QLabel, QLineEdit, QListWidget, QListWidgetItem, QPlainTextEdit,
QPushButton, QScrollArea, QSpinBox, QSplitter, QTabWidget, QVBoxLayout,
QWidget,
)
from ..config import PROVIDER_LABELS
from ..core.custom_agents import list_agents
from ..core.flows import (
Flow, FlowStep, SubAgent, default_req_to_demo, delete_flow, list_flows,
save_flow,
)
from ..core.skills import list_skills
from ..core.worker import AgentWorker
from ..i18n import tr
from .agent_manager_tab import AgentManagerTab
from .icons import icon
from .skill_manager_tab import SkillManagerTab
class FlowBuilderDialog(QDialog):
"""Trình dựng luồng cũ: danh sách bước bên trái, form sửa bước bên phải.
Đã được Co4E Studio thay thế và không còn nơi nào mở nó ra; giữ lại làm bản
đối chiếu cho phần luồng nhiều bước.
Một luồng là danh sách ``FlowStep`` chạy tuần tự; mỗi bước có thể chọn
riêng skill, provider, model, tệp đính kèm và các sub-agent chạy kèm.
"""
def __init__(self, parent=None, ctx=None):
"""Dựng hộp thoại và nạp sẵn luồng mẫu để người dùng có cái mà sửa ngay."""
super().__init__(parent)
self.setWindowTitle(tr("flow.title"))
self.setMinimumSize(820, 560)
self.run_requested = False
self._ctx = ctx # for the AI "generate task from hint" button
self._gen_worker = None
self._flow = default_req_to_demo()
self._loaded_name = "" # template name currently loaded (for rename-safe save)
root = QVBoxLayout(self)
# The dialog hosts two inner tabs: the Flow builder itself, and the
# Agent Manager (create/edit/delete reusable Agent presets) so both
# live in one window instead of a separate top-level app tab.
self.tabs = QTabWidget()
flow_page = QWidget()
fl = QVBoxLayout(flow_page)
# --- template bar ---
bar = QHBoxLayout()
bar.addWidget(QLabel(tr("flow.template")))
self.tpl_combo = QComboBox()
self.tpl_combo.activated.connect(self._load_selected_template)
bar.addWidget(self.tpl_combo, 1)
tpl_btn = QPushButton(tr("flow.load_builtin"))
tpl_btn.setIcon(icon("download"))
tpl_btn.clicked.connect(self._load_builtin)
new_btn = QPushButton(tr("flow.new"))
new_btn.setIcon(icon("new"))
new_btn.clicked.connect(self._new_flow)
del_btn = QPushButton(tr("flow.delete_template"))
del_btn.setIcon(icon("trash"))
del_btn.clicked.connect(self._delete_template)
for b in (tpl_btn, new_btn, del_btn):
bar.addWidget(b)
fl.addLayout(bar)
# --- name/description ---
meta = QFormLayout()
self.name_edit = QLineEdit()
self.desc_edit = QLineEdit()
meta.addRow(tr("flow.name_label"), self.name_edit)
meta.addRow(tr("flow.description_label"), self.desc_edit)
fl.addLayout(meta)
# --- steps list | step editor ---
split = QSplitter(Qt.Horizontal)
left = QWidget()
ll = QVBoxLayout(left)
ll.addWidget(QLabel(tr("flow.stages")))
self.steps_list = QListWidget()
self.steps_list.currentRowChanged.connect(self._load_step_into_editor)
ll.addWidget(self.steps_list, 1)
order = QHBoxLayout()
up = QPushButton("↑")
up.clicked.connect(lambda: self._move(-1))
down = QPushButton("↓")
down.clicked.connect(lambda: self._move(1))
rm = QPushButton(tr("flow.remove_stage"))
rm.setIcon(icon("trash"))
rm.clicked.connect(self._remove_step)
for b in (up, down, rm):
order.addWidget(b)
ll.addLayout(order)
split.addWidget(left)
right_content = QWidget()
rl = QFormLayout(right_content)
rl.setRowWrapPolicy(QFormLayout.WrapLongRows)
rl.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
self.step_name = QLineEdit()
self.step_hint = QLineEdit()
self.step_prompt = QPlainTextEdit()
self.step_prompt.setMinimumHeight(120)
self.step_skill = QComboBox()
# "AI provider" (openai_compat / anthropic / …) + the "Agent" (model)
# WITHIN that provider (DeepSeek, qwen, … — fetched live from the
# provider's own model list), side by side on one row.
self.step_agent = QComboBox()
self.step_model = QComboBox()
self.step_model.addItem(tr("flow.default_model"), "")
self.step_agent.currentIndexChanged.connect(self._reload_step_models)
agent_row = QWidget()
arow = QHBoxLayout(agent_row)
arow.setContentsMargins(0, 0, 0, 0)
arow.addWidget(self.step_agent, 1)
arow.addWidget(QLabel(tr("flow.model_label")))
arow.addWidget(self.step_model, 1)
# Task field with an AI "generate from hint" button on top.
task_box = QWidget()
tb = QVBoxLayout(task_box)
tb.setContentsMargins(0, 0, 0, 0)
self._gen_prompt_btn = QPushButton(tr("flow.gen_task_from_hint"))
self._gen_prompt_btn.setIcon(icon("sparkle"))
self._gen_prompt_btn.setToolTip(tr("flow.gen_task_tooltip"))
self._gen_prompt_btn.clicked.connect(self._gen_prompt)
tb.addWidget(self._gen_prompt_btn)
tb.addWidget(self.step_prompt)
rl.addRow(tr("flow.stage_name"), self.step_name)
rl.addRow(tr("flow.hint"), self.step_hint) # Hint sits above the task
rl.addRow(tr("flow.task_prompt"), task_box)
rl.addRow(tr("flow.skill"), self.step_skill)
rl.addRow(tr("flow.agent"), agent_row)
# --- attachments ---
self._step_attachments: List[str] = []
self._step_subagents: List[SubAgent] = []
self.attach_btn = QPushButton(tr("flow.attach_files"))
self.attach_btn.setIcon(icon("attach"))
self.attach_btn.clicked.connect(self._pick_attachments)
rl.addRow(tr("flow.attachments"), self.attach_btn)
# --- per-stage execution options ---
self.compact_chk = QCheckBox(tr("flow.compact_after_run"))
self.compact_chk.setToolTip(tr("flow.compact_after_run_tooltip"))
rl.addRow("", self.compact_chk)
self.verify_chk = QCheckBox(tr("flow.self_verify"))
self.verify_chk.setToolTip(tr("flow.self_verify_tooltip"))
rl.addRow("", self.verify_chk)
self.retries_spin = QSpinBox()
self.retries_spin.setRange(0, 10)
self.retries_spin.setToolTip(tr("flow.review_retries_tooltip"))
rl.addRow(tr("flow.review_retries"), self.retries_spin)
# --- parallel sub-agents (non-empty => this stage fans out) ---
sub_box = QWidget()
sb = QVBoxLayout(sub_box)
sb.setContentsMargins(0, 0, 0, 0)
self.subagents_list = QListWidget()
# No cap on the number of sub-agents; the list itself scrolls once it
# grows past this height instead of stretching (and overlapping) the
# rest of the form.
self.subagents_list.setMinimumHeight(90)
self.subagents_list.setMaximumHeight(160)
sb.addWidget(self.subagents_list)
sub_form = QHBoxLayout()
self.sub_name_edit = QLineEdit()
self.sub_name_edit.setPlaceholderText(tr("flow.subagent_name_placeholder"))
self.sub_prompt_edit = QLineEdit()
self.sub_prompt_edit.setPlaceholderText(tr("flow.subagent_task_placeholder"))
sub_add = QPushButton(tr("flow.subagent_add"))
sub_add.setIcon(icon("plus"))
sub_add.clicked.connect(self._add_subagent)
sub_remove = QPushButton(tr("flow.subagent_remove"))
sub_remove.setIcon(icon("minus"))
sub_remove.clicked.connect(self._remove_subagent)
sub_form.addWidget(self.sub_name_edit)
sub_form.addWidget(self.sub_prompt_edit, 1)
sub_form.addWidget(sub_add)
sub_form.addWidget(sub_remove)
sb.addLayout(sub_form)
# --- add a sub-agent straight from a saved custom Agent preset ---
agent_form = QHBoxLayout()
self.agent_picker = QComboBox()
self._reload_agent_picker()
agent_form.addWidget(self.agent_picker, 1)
agent_add = QPushButton(tr("flow.subagent_add_from_agent"))
agent_add.setIcon(icon("plus"))
agent_add.clicked.connect(self._add_subagent_from_agent)
agent_form.addWidget(agent_add)
sb.addLayout(agent_form)
sub_hint = QLabel(tr("flow.subagent_hint"))
sub_hint.setObjectName("hint")
sub_hint.setWordWrap(True)
sb.addWidget(sub_hint)
rl.addRow(tr("flow.parallel_agents"), sub_box)
step_btns = QHBoxLayout()
add_step = QPushButton(tr("flow.add_stage"))
add_step.setIcon(icon("plus"))
add_step.setObjectName("primary")
add_step.clicked.connect(self._add_step)
upd_step = QPushButton(tr("flow.update_stage"))
upd_step.setIcon(icon("refresh"))
upd_step.clicked.connect(self._update_step)
step_btns.addWidget(add_step)
step_btns.addWidget(upd_step)
rl.addRow(step_btns)
# The step editor form can grow tall (many rows + a growing sub-agent
# list); scroll it instead of letting rows compress/overlap when the
# dialog is smaller than the content's natural height.
right_scroll = QScrollArea()
right_scroll.setWidgetResizable(True)
right_scroll.setFrameShape(QScrollArea.NoFrame)
right_scroll.setWidget(right_content)
split.addWidget(right_scroll)
split.setSizes([300, 520])
fl.addWidget(split, 1)
# --- bottom actions ---
actions = QHBoxLayout()
save_btn = QPushButton(tr("flow.save_template"))
save_btn.setIcon(icon("save"))
save_btn.clicked.connect(self._save_template)
run_btn = QPushButton(tr("flow.run"))
run_btn.setIcon(icon("play"))
run_btn.setObjectName("primary")
run_btn.clicked.connect(self._run)
close_btn = QPushButton(tr("flow.close"))
close_btn.setIcon(icon("close"))
close_btn.clicked.connect(self.reject)
actions.addWidget(save_btn)
actions.addStretch(1)
actions.addWidget(run_btn)
actions.addWidget(close_btn)
fl.addLayout(actions)
self.tabs.addTab(flow_page, tr("flow.tab_flow"))
self.agent_manager_tab = AgentManagerTab(self._ctx)
self.tabs.addTab(self.agent_manager_tab, tr("flow.tab_agents"))
self.skill_manager_tab = SkillManagerTab(self._ctx)
self.tabs.addTab(self.skill_manager_tab, tr("flow.tab_skills"))
# Agents/skills created/edited/deleted on their tabs must show up back
# on the Flow tab (sub-agent picker / step skill combo) without
# reopening the whole dialog — cheap enough (reads a small folder) to
# just refresh on every tab switch rather than wiring a change signal.
self.tabs.currentChanged.connect(lambda _i: self._reload_agent_picker())
self.tabs.currentChanged.connect(lambda _i: self._reload_skill_combo())
root.addWidget(self.tabs, 1)
self._populate_combos()
self._reload_templates()
self._bind_flow(self._flow)
# ---- AI: generate task prompt from the hint ---------------------
def _gen_prompt(self) -> None:
"""Nhờ model viết nội dung bước từ tên bước và câu gợi ý ngắn.
Không có ``ctx`` (mở hộp thoại ngoài ứng dụng) thì nút này không làm gì.
"""
hint = self.step_hint.text().strip()
name = self.step_name.text().strip()
if not hint and not name:
self.step_hint.setFocus()
return
if self._ctx is None:
return
self._gen_prompt_btn.setEnabled(False)
self._gen_prompt_btn.setText(tr("skills.generating"))
ctx = self._ctx
def job(worker: AgentWorker):
"""Chạy nền: gọi model sinh nội dung cho bước."""
from ..core.flows import generate_task_prompt
return {"prompt": generate_task_prompt(
ctx.build_active_provider(), name, hint, worker.is_cancelled)}
w = AgentWorker(job)
w.finished_ok.connect(self._on_gen_prompt)
w.failed.connect(lambda _e: self._reset_gen_prompt_btn())
self._gen_worker = w
w.start()
def _on_gen_prompt(self, result) -> None:
"""Đổ nội dung model vừa sinh vào ô soạn, rồi trả nút về trạng thái thường."""
text = (result or {}).get("prompt", "")
if text:
self.step_prompt.setPlainText(text)
self._reset_gen_prompt_btn()
def _reset_gen_prompt_btn(self) -> None:
"""Bật lại nút sinh nội dung và trả chữ về như cũ."""
self._gen_prompt_btn.setEnabled(True)
self._gen_prompt_btn.setText(tr("flow.gen_task_from_hint"))
# ---- attachments (per stage) -------------------------------------
def _pick_attachments(self) -> None:
"""Chọn tệp đính kèm cho bước đang sửa."""
chosen, _ = QFileDialog.getOpenFileNames(self, tr("flow.attach_files"))
if chosen:
self._step_attachments = chosen
self._refresh_attach_btn()
def _refresh_attach_btn(self) -> None:
"""Cập nhật nhãn nút đính kèm theo số tệp; tooltip liệt kê đủ đường dẫn."""
n = len(self._step_attachments)
label = tr("flow.attach_files_count", n=n) if n else tr("flow.attach_files")
self.attach_btn.setText(label)
self.attach_btn.setToolTip("\n".join(self._step_attachments))
# ---- parallel sub-agents (per stage) ------------------------------
# ``self._step_subagents`` is the source of truth; ``subagents_list`` is
# just its display — avoids any lossy re-parsing of the list widget text.
def _add_subagent(self) -> None:
"""Thêm một sub-agent tự nhập (tên + nội dung) vào bước đang sửa."""
name = self.sub_name_edit.text().strip()
if not name:
self.sub_name_edit.setFocus()
return
prompt = self.sub_prompt_edit.text().strip()
self._step_subagents.append(SubAgent(name=name, prompt=prompt))
self.sub_name_edit.clear()
self.sub_prompt_edit.clear()
self._refresh_subagents_list()
def _remove_subagent(self) -> None:
"""Gỡ sub-agent đang chọn khỏi bước."""
row = self.subagents_list.currentRow()
if 0 <= row < len(self._step_subagents):
self._step_subagents.pop(row)
self._refresh_subagents_list()
def _refresh_subagents_list(self) -> None:
"""Vẽ lại danh sách sub-agent của bước đang sửa."""
self.subagents_list.clear()
for sub in self._step_subagents:
text = f"{sub.name}: {sub.prompt}" if sub.prompt else sub.name
self.subagents_list.addItem(QListWidgetItem(text))
# ---- add a sub-agent from a saved custom Agent preset -------------
def _reload_agent_picker(self) -> None:
"""Nạp danh sách agent tự tạo vào bộ chọn; chưa có agent nào thì khoá bộ chọn
lại kèm dòng giải thích, thay vì để một ô rỗng bấm được mà không ra gì.
"""
self.agent_picker.clear()
agents = list_agents()
if not agents:
self.agent_picker.addItem(tr("flow.subagent_no_agents"), None)
self.agent_picker.setEnabled(False)
return
self.agent_picker.setEnabled(True)
for a in agents:
self.agent_picker.addItem(a.name, a)
def _add_subagent_from_agent(self) -> None:
"""Thêm sub-agent bằng cách lấy nguyên một agent tự tạo (kèm provider và model
riêng của nó).
"""
agent = self.agent_picker.currentData()
if agent is None:
return
self._step_subagents.append(
SubAgent(name=agent.name, prompt=agent.prompt, agent=agent.provider,
model=agent.model))
self._refresh_subagents_list()
# ---- combos / templates -----------------------------------------
def _reload_skill_combo(self) -> None:
"""Skills added/edited/deleted on the Skills tab must show up here
without reopening the dialog — same reasoning as _reload_agent_picker
(cheap: reads a small folder)."""
current = self.step_skill.currentData()
self.step_skill.blockSignals(True)
self.step_skill.clear()
self.step_skill.addItem(tr("flow.none"), "")
for s in list_skills():
self.step_skill.addItem(s.name, s.name)
idx = self.step_skill.findData(current)
self.step_skill.setCurrentIndex(max(0, idx))
self.step_skill.blockSignals(False)
def _populate_combos(self) -> None:
"""Nạp các bộ chọn của form bước: skill, provider và model."""
self._reload_skill_combo()
self.step_agent.blockSignals(True)
self.step_agent.clear()
self.step_agent.addItem(tr("flow.default_agent"), "")
for key, label in PROVIDER_LABELS.items():
self.step_agent.addItem(label, key)
self.step_agent.blockSignals(False)
self._reload_step_models()
def _reload_step_models(self) -> None:
"""Fill the step's Agent (model) combo with the models the selected
AI provider actually serves (DeepSeek, qwen, … — fetched live in the
background so the dialog never blocks on a network call)."""
if self._ctx is None: # dialog opened without an app context (tests)
return
provider_key = self.step_agent.currentData() or self._ctx.config.active_provider
keep = getattr(self, "_pending_step_model", "") or (self.step_model.currentData() or "")
self.step_model.clear()
self.step_model.addItem(tr("flow.default_model"), "")
if keep: # keep the stored choice selectable even before the fetch lands
self.step_model.addItem(keep, keep)
self.step_model.setCurrentIndex(1)
def job(worker: AgentWorker):
"""Chạy nền: hỏi provider danh sách model. Lỗi thì trả danh sách rỗng — danh
sách model chỉ là tiện ích, không đáng làm vỡ hộp thoại.
"""
try:
provider = self._ctx.build_provider_for(provider_key)
return {"models": provider.list_models() or [], "provider": provider_key}
except Exception: # noqa: BLE001 — model list is best-effort
return {"models": [], "provider": provider_key}
def done(result: dict) -> None:
"""Đổ danh sách model vào bộ chọn.
Kết quả về mà provider đã bị đổi lần nữa thì bỏ — nếu không, danh sách của
provider cũ sẽ đè lên provider người dùng vừa chọn.
"""
if result.get("provider") != (self.step_agent.currentData()
or self._ctx.config.active_provider):
return # provider changed again while fetching — stale reply
current = self.step_model.currentData() or ""
self.step_model.blockSignals(True)
self.step_model.clear()
self.step_model.addItem(tr("flow.default_model"), "")
for m in result.get("models", []):
self.step_model.addItem(m, m)
if current and self.step_model.findData(current) < 0:
self.step_model.addItem(current, current)
idx = self.step_model.findData(current)
self.step_model.setCurrentIndex(max(0, idx))
self.step_model.blockSignals(False)
w = AgentWorker(job)
w.finished_ok.connect(done)
w.failed.connect(lambda _e: None)
self._model_workers = getattr(self, "_model_workers", [])
self._model_workers.append(w) # keep a ref so the thread isn't GC'd
w.start()
def _reload_templates(self) -> None:
"""Nạp lại bộ chọn luồng đã lưu."""
self.tpl_combo.blockSignals(True)
self.tpl_combo.clear()
self.tpl_combo.addItem(tr("flow.select_template"), None)
for flow in list_flows():
self.tpl_combo.addItem(flow.name, flow)
self.tpl_combo.blockSignals(False)
def _load_selected_template(self, _idx: int) -> None:
"""Mở luồng vừa chọn trong bộ chọn.
Nạp một BẢN SAO SÂU: người dùng sửa rồi bỏ ngang thì luồng đã lưu vẫn
nguyên vẹn.
"""
flow = self.tpl_combo.currentData()
if isinstance(flow, Flow):
self._loaded_name = flow.name
self._bind_flow(copy.deepcopy(flow))
def _load_builtin(self) -> None:
"""Nạp lại luồng mẫu dựng sẵn."""
self._loaded_name = ""
self._bind_flow(default_req_to_demo())
def _new_flow(self) -> None:
"""Bắt đầu một luồng trống mới."""
self._loaded_name = ""
self._bind_flow(Flow(name=tr("flow.new_flow_name"), description="", steps=[]))
def _delete_template(self) -> None:
"""Xoá luồng đang chọn khỏi danh sách đã lưu."""
flow = self.tpl_combo.currentData()
if isinstance(flow, Flow):
delete_flow(flow.name)
self._reload_templates()
# ---- flow <-> widgets -------------------------------------------
def _bind_flow(self, flow: Flow) -> None:
"""Đổ một luồng vào toàn bộ giao diện và chọn sẵn bước đầu tiên."""
self._flow = flow
self.name_edit.setText(flow.name)
self.desc_edit.setText(flow.description)
self._refresh_steps()
if flow.steps:
self.steps_list.setCurrentRow(0)
else:
self._clear_editor()
def _refresh_steps(self) -> None:
"""Vẽ lại danh sách bước, mỗi dòng kèm nhãn skill/provider/model của bước đó."""
self.steps_list.blockSignals(True)
self.steps_list.clear()
for i, step in enumerate(self._flow.steps, 1):
tags = []
if step.skill:
tags.append(f"[skill:{step.skill}]")
if step.agent or step.model:
label = PROVIDER_LABELS.get(step.agent, step.agent) if step.agent else ""
combo = "·".join(x for x in (label, step.model) if x)
tags.append(f"[agent:{combo}]")
if step.is_parallel:
tags.append(f"[parallel×{len(step.parallel_agents)}]")
if step.attachments:
tags.append(f"[files:{len(step.attachments)}]")
if step.self_verify or step.review_retries:
tags.append("[verify]")
suffix = (" " + " ".join(tags)) if tags else ""
self.steps_list.addItem(QListWidgetItem(f"{i}. {step.name}{suffix}"))
self.steps_list.blockSignals(False)
def _clear_editor(self) -> None:
"""Xoá trắng form sửa bước."""
self.step_name.clear()
self.step_prompt.clear()
self.step_hint.clear()
self.step_skill.setCurrentIndex(0)
self.step_agent.setCurrentIndex(0)
self._pending_step_model = ""
self.step_model.setCurrentIndex(0)
self._step_attachments = []
self._refresh_attach_btn()
self.compact_chk.setChecked(False)
self.verify_chk.setChecked(False)
self.retries_spin.setValue(0)
self._step_subagents = []
self._refresh_subagents_list()
def _load_step_into_editor(self, row: int) -> None:
"""Đổ một bước vào form sửa.
Model được nhớ riêng vào ``_pending_step_model`` vì danh sách model nạp bất
đồng bộ — không giữ lại thì lựa chọn của người dùng biến mất khi danh sách
về tới nơi.
"""
if not (0 <= row < len(self._flow.steps)):
return
step = self._flow.steps[row]
self.step_name.setText(step.name)
self.step_prompt.setPlainText(step.prompt)
self.step_hint.setText(step.hint)
self.step_skill.setCurrentIndex(max(0, self.step_skill.findData(step.skill)))
self._pending_step_model = step.model # survives the async model fetch
self.step_agent.setCurrentIndex(max(0, self.step_agent.findData(step.agent)))
if self.step_model.findData(step.model) < 0 and step.model:
self.step_model.addItem(step.model, step.model)
self.step_model.setCurrentIndex(max(0, self.step_model.findData(step.model)))
self._step_attachments = list(step.attachments)
self._refresh_attach_btn()
self.compact_chk.setChecked(step.compact_after_run)
self.verify_chk.setChecked(step.self_verify)
self.retries_spin.setValue(step.review_retries)
self._step_subagents = list(step.parallel_agents)
self._refresh_subagents_list()
def _editor_step(self) -> Optional[FlowStep]:
"""Dựng ``FlowStep`` từ nội dung form; ``None`` nếu chưa nhập tên bước (con trỏ
nhảy vào ô tên thay vì hiện hộp lỗi).
"""
name = self.step_name.text().strip()
if not name:
self.step_name.setFocus()
return None
return FlowStep(
name=name,
prompt=self.step_prompt.toPlainText().strip(),
skill=self.step_skill.currentData() or "",
agent=self.step_agent.currentData() or "",
model=self.step_model.currentData() or "",
hint=self.step_hint.text().strip(),
attachments=list(self._step_attachments),
compact_after_run=self.compact_chk.isChecked(),
self_verify=self.verify_chk.isChecked(),
review_retries=self.retries_spin.value(),
parallel_agents=list(self._step_subagents),
)
def _add_step(self) -> None:
"""Thêm bước đang soạn vào cuối luồng và chọn nó."""
step = self._editor_step()
if step:
self._flow.steps.append(step)
self._refresh_steps()
self.steps_list.setCurrentRow(len(self._flow.steps) - 1)
def _update_step(self) -> None:
"""Ghi đè bước đang chọn bằng nội dung form."""
row = self.steps_list.currentRow()
step = self._editor_step()
if step and 0 <= row < len(self._flow.steps):
self._flow.steps[row] = step
self._refresh_steps()
self.steps_list.setCurrentRow(row)
def _remove_step(self) -> None:
"""Xoá bước đang chọn khỏi luồng."""
row = self.steps_list.currentRow()
if 0 <= row < len(self._flow.steps):
self._flow.steps.pop(row)
self._refresh_steps()
def _move(self, delta: int) -> None:
"""Đổi chỗ bước đang chọn lên/xuống một vị trí — thứ tự bước chính là thứ tự
chạy.
"""
row = self.steps_list.currentRow()
new = row + delta
if 0 <= row < len(self._flow.steps) and 0 <= new < len(self._flow.steps):
steps = self._flow.steps
steps[row], steps[new] = steps[new], steps[row]
self._refresh_steps()
self.steps_list.setCurrentRow(new)
# ---- result -----------------------------------------------------
def _collect(self) -> Flow:
"""Lấy tên và mô tả từ form vào luồng rồi trả về nó. Tên bỏ trống thì dùng tên
mặc định, không để luồng không tên.
"""
self._flow.name = self.name_edit.text().strip() or tr("flow.default_name")
self._flow.description = self.desc_edit.text().strip()
return self._flow
def _save_template(self) -> None:
"""Lưu luồng hiện tại. Truyền ``old_name`` để đổi tên không sinh ra bản thứ hai."""
flow = self._collect()
save_flow(flow, old_name=self._loaded_name)
self._loaded_name = flow.name
self._reload_templates()
idx = self.tpl_combo.findText(flow.name)
if idx >= 0:
self.tpl_combo.setCurrentIndex(idx)
def _run(self) -> None:
"""Đóng hộp thoại kèm yêu cầu chạy luồng. Luồng chưa có bước nào thì không làm
gì.
"""
flow = self._collect()
if not flow.steps:
return
self.run_requested = True
self.accept()
def result_flow(self) -> Flow:
"""Luồng cuối cùng bên gọi nhận về sau khi hộp thoại đóng."""
return self._collect()