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