Files
cowork-local/ui/flow_dialog.py
T
2026-08-09 20:12:05 +07:00

597 lines
25 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):
def __init__(self, parent=None, ctx=None):
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:
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):
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:
text = (result or {}).get("prompt", "")
if text:
self.step_prompt.setPlainText(text)
self._reset_gen_prompt_btn()
def _reset_gen_prompt_btn(self) -> None:
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:
chosen, _ = QFileDialog.getOpenFileNames(self, tr("flow.attach_files"))
if chosen:
self._step_attachments = chosen
self._refresh_attach_btn()
def _refresh_attach_btn(self) -> None:
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:
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:
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:
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:
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:
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:
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):
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:
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:
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:
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:
self._loaded_name = ""
self._bind_flow(default_req_to_demo())
def _new_flow(self) -> None:
self._loaded_name = ""
self._bind_flow(Flow(name=tr("flow.new_flow_name"), description="", steps=[]))
def _delete_template(self) -> None:
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:
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:
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:
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:
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]:
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:
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:
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:
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:
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:
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:
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:
flow = self._collect()
if not flow.steps:
return
self.run_requested = True
self.accept()
def result_flow(self) -> Flow:
return self._collect()