Files
f9f6bc01fd
CI / test (push) Canceled after 0s
Feature/delta team/epic r04 (#7)
## 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>
2026-08-31 05:15:13 +00:00

668 lines
29 KiB
Python
Raw Permalink 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()