chore(repo): initialize Cowork Local Gitea repository
CI / test (push) Canceled after 0s

This commit is contained in:
thanhnv
2026-08-09 20:12:05 +07:00
commit 414eaddca3
192 changed files with 48160 additions and 0 deletions
+596
View File
@@ -0,0 +1,596 @@
"""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()