refactor(co4e): co4e_tab.py 1885 -> 389, Co4ETab tách thành 7 mixin

File to nhất còn lại của Gamma. Lâm bàn giao ở 1.885 dòng với 100 method
trong một lớp; chia theo bảy mối quan tâm:

    co4e_runs.py           364   chạy flow, 3 chế độ, bảng lịch sử lượt chạy
    co4e_chat.py           343   khung chat + đếm token + định tuyến riêng
    co4e_layout.py         308   ba khung, bảng cấu hình, bố cục màn hẹp
    co4e_sidebar.py        251   thư viện workflow/agent/skill, 4 mục gập
    co4e_flow_tabs.py      180   dải tab các flow đang mở
    co4e_workflow_crud.py  154   tạo/sửa/xoá/nhân bản workflow
    co4e_agents.py          51   agent và skill dùng trong flow
    ui/co4e_tab.py         389   __init__, set_project, thư mục output

Mọi file dưới 400 dòng.

MỘT LỖI SUÝT LÀM HỎNG FILE: bản đầu tôi cắt method theo m.lineno, mà lineno
trỏ vào dòng `def`, không tính dòng `@...` phía trên. Decorator bị bỏ lại
thành mồ côi ngay trên một hằng số lớp -> file hỏng cú pháp. Bắt được vì
script tự parse lại sau mỗi lần cắt; nếu chỉ cắt rồi ghi thì đã đẩy lên một
file không import nổi.

Ba vòng sửa mức import tương đối: co4e_tab.py nằm ở ui/ (1 cấp), file mới ở
presentation/co4e/ (2 cấp). Còn co4e_canvas / co4e_config_panel /
co4e_agent_dialog thì VẪN ở ui/, nên `.co4e_canvas` phải thành
`...ui.co4e_canvas` chứ không phải `.co4e_canvas` cùng thư mục.

714 test xanh — trong đó có ~4.000 dòng test đặc tả Lâm viết cho đúng vùng
này, nên việc tách được soi khá kỹ. check_co4e, check_controls_alive,
check_layout_geometry, check_probes_bite đều qua.

Cập nhật đích đột biến thứ ba của check_probes_bite: dải tab flow nay ở
presentation/co4e/co4e_layout.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nam Pham Dinh Thanh
2026-08-26 10:43:33 +09:00
co-authored by Claude Opus 5
parent 70a0c2fdcf
commit 0e00bf3c2f
9 changed files with 1671 additions and 1515 deletions
+51
View File
@@ -0,0 +1,51 @@
"""Agent và skill dùng trong flow — R08-T09.
"""
from __future__ import annotations
import re
from typing import Dict, List
from PySide6.QtCore import QSize, Qt
from ...core import co4e, skills as skills_mod
from ...i18n import tr
from ...presentation.co4e.co4e_chat_view import _skill_names
class Co4EAgentsMixin:
def _new_agent(self) -> None:
self._edit_agent_dialog(co4e.new_custom_agent(""))
def _edit_agent(self) -> None:
item = self.agent_list.currentItem()
cid = item.data(Qt.UserRole + 1) if item else None
if not cid:
self.status_message.emit(tr("co4e.select_custom_agent"))
return
agent = next((a for a in co4e.list_custom_agents() if a.id == cid), None)
if agent is not None:
self._edit_agent_dialog(agent)
def _edit_agent_dialog(self, agent: co4e.CustomAgent) -> None:
from ...ui.co4e_agent_dialog import Co4EAgentDialog
dlg = Co4EAgentDialog(self.ctx, agent, _skill_names(), self)
if dlg.exec():
co4e.save_custom_agent(dlg.result_agent())
self._reload_sidebar()
def _delete_agent(self) -> None:
item = self.agent_list.currentItem()
cid = item.data(Qt.UserRole + 1) if item else None
if not cid:
self.status_message.emit(tr("co4e.select_custom_agent"))
return
co4e.delete_custom_agent(cid)
self._reload_sidebar()
def _manage_skills(self) -> None:
from ...ui.skills_dialog import SkillsDialog
SkillsDialog(self, self.ctx).exec()
self._reload_sidebar()
def _skill_map(self) -> Dict[str, str]:
out = {}
for name in _skill_names():
block = skills_mod.skill_prefix_for(name)
if block:
out[name] = block.split("\n", 1)[1] if "\n" in block else block
return out
+343
View File
@@ -0,0 +1,343 @@
"""Khung chat của Co4E và việc đếm token — R08-T09.
Khác chat của Cowork ở một điểm: ở đây câu người dùng gõ có thể mang chỉ thị
chọn agent (``_extract_agent_directive``), và mỗi lượt được định tuyến riêng
theo cấu hình routing của Co4E.
"""
from __future__ import annotations
import re
from typing import Dict, List
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QSplitter, QWidget
from ...core import co4e, skills as skills_mod
from ...core.co4e_builtins import BUILTIN_AGENTS
from ...core.worker import AgentWorker
from ...i18n import tr
from ...ui.chat_view import ChatView
from ...ui.icons import icon
from ...presentation.co4e.co4e_chat_view import ChatPanel
class Co4EChatMixin:
def _build_chat(self) -> QWidget:
"""Widget construction lives in ``ChatPanel`` (presentation/co4e/
co4e_chat_view.py); this method just wires the panel's public
attributes to the handler methods that know about ``self``
(``_toggle_messages``, ``_chat_send``) and keeps the state that is
NOT part of the panel's own construction (``_flow_logs`` — per-flow
ChatView dict, ``_co4e_routed_provider`` — routing override, and
``_vsplit_sizes``/``_msgs_collapsed`` — used by ``_toggle_messages``
below to restore/collapse the splitter) — the panel itself stays
ignorant of ``Co4ETab``.
"""
panel = ChatPanel(self.ctx)
self._chat_widget = panel
self.msgs_icon = panel.msgs_icon
self.msgs_title = panel.msgs_title
self.chat_toggle_btn = panel.chat_toggle_btn
self.chat_toggle_btn.clicked.connect(self._toggle_messages)
self._mhdr = panel.header
self.chat_stack = panel.chat_stack
self._flow_logs: Dict[str, ChatView] = {}
self.chat_input_row = panel.chat_input_row
self._usage_total_lbl = panel.usage_total_lbl
self.chat_input = panel.chat_input
self.chat_input.submit.connect(self._chat_send)
self.chat_send_btn = panel.chat_send_btn
self.chat_send_btn.clicked.connect(self._chat_send)
self.co4e_routing_toggle = panel.co4e_routing_toggle
self._co4e_routed_provider = None # routing provider override for the next turn
self._vsplit_sizes = [540, 220] # sizes to restore when expanded
self._msgs_collapsed = True
return panel
def _toggle_messages(self) -> None:
"""Show/hide the WHOLE chat box (message list + composer) below the
header. Collapsing hands the freed height to the canvas.
A QSplitter's ``setMaximumHeight`` on one side does NOT automatically
redistribute the freed space to the other side — it just shrinks the
splitter's own total height, leaving the canvas frozen at its old size
and blank space below it. So this explicitly calls ``setSizes`` on both
the collapse AND the expand path, computed from the splitter's CURRENT
total (not a hardcoded guess) — that total stays constant; only how
it's split between canvas/chat changes."""
self._msgs_collapsed = not self._msgs_collapsed
collapsed_h = self._mhdr.sizeHint().height() + 6
if self._msgs_collapsed:
if hasattr(self, "_vsplit"):
self._vsplit_sizes = self._vsplit.sizes() # remember to restore
self.chat_stack.hide()
self.chat_input_row.hide()
self._chat_widget.setMaximumHeight(collapsed_h)
self.chat_toggle_btn.setIcon(icon("chevron-up")) # collapsed → click to expand
self.chat_toggle_btn.setToolTip(tr("co4e.tt_expand_msgs"))
if hasattr(self, "_vsplit"):
total = sum(self._vsplit.sizes()) or (self._vsplit_sizes and sum(self._vsplit_sizes)) or 760
self._vsplit.setSizes([max(0, total - collapsed_h), collapsed_h])
else:
self._chat_widget.setMaximumHeight(16777215)
self.chat_stack.show()
self.chat_input_row.show()
self.chat_toggle_btn.setIcon(icon("chevron-down")) # expanded → click to collapse
self.chat_toggle_btn.setToolTip(tr("co4e.tt_collapse_msgs"))
if getattr(self, "_vsplit_sizes", None) and hasattr(self, "_vsplit"):
self._vsplit.setSizes(self._vsplit_sizes)
return
def _ensure_flow_log(self, wf_id: str) -> ChatView:
"""The ChatView for a flow, created + added to the stack on first use so
each flow tab keeps a SEPARATE conversation."""
log = self._flow_logs.get(wf_id)
if log is None:
log = ChatView()
log._co4e_plan_bubble = None # per-flow 'current plan' bubble
self._flow_logs[wf_id] = log
self.chat_stack.addWidget(log)
return log
def _active_log(self) -> ChatView:
wf = getattr(self, "_wf", None)
return self._ensure_flow_log(wf.id if wf is not None else "__none__")
@property
def chat_log(self) -> ChatView:
"""The conversation of the CURRENTLY-shown flow (all append/stream calls
go here). Assignment is not supported — logs are per-flow now."""
return self._active_log()
@property
def _plan_bubble(self):
return getattr(self._active_log(), "_co4e_plan_bubble", None)
@_plan_bubble.setter
def _plan_bubble(self, value) -> None:
self._active_log()._co4e_plan_bubble = value
def _chat_send(self) -> None:
text = self.chat_input.text().strip()
if not text or self._chat_worker is not None:
return
self.chat_input.clear()
self._append_chat("user", text)
skill_prefix, request, info = skills_mod.parse_skill_command(text)
if info is not None:
self._append_chat("system", info)
return
system_parts = []
if skill_prefix:
system_parts.append(skill_prefix)
agent_name, request = self._extract_agent_directive(request)
model = ""
if agent_name:
persona = self._resolve_agent(agent_name)
if persona is None:
self._append_chat("system", tr("co4e.agent_not_found", name=agent_name))
return
system_parts.append(persona[0])
model = persona[1]
# Auto Model Routing — only when the user hasn't pinned an agent's own
# model (an explicit pin wins). May switch provider+model for this turn.
if not model:
model = self._apply_co4e_routing(request)
self._run_chat_turn(system_parts, request, model)
def _apply_co4e_routing(self, request: str) -> str:
"""Route this Co4E turn to the best-fit model. Returns the model id to
use ('' → provider default) and sets ``self._co4e_routed_provider`` when
a cross-provider switch is chosen.
R03-T05: the Off/Auto/Manual/Fallback rules are no longer re-implemented
here — they come from the shared ``RoutingApplicationService``, so Co4E,
the Cowork chat and AI-Edit can never drift apart again. This method only
adapts between Co4E's state and the service's DTOs. Never raises — falls
back to the default model on any error.
"""
self._co4e_routed_provider = None
try:
from ...application.model_routing import (
RoutingRequest,
build_routing_application_service,
)
from ...ui.routing_toggle import confirm_switch
cur_provider = self.ctx.config.active_provider
cur_model = self.ctx.config.provider_conf(cur_provider).get("model", "")
outcome = build_routing_application_service(self.ctx).resolve(
RoutingRequest(
surface="co4e",
prompt=request,
current_provider=cur_provider,
current_model=cur_model,
),
confirm=lambda decision, timeout: confirm_switch(self, decision, timeout),
)
if not outcome.switched:
return "" # '' keeps the provider's configured default model
# Remembered so the worker's build_provider_for() can follow a
# cross-provider switch, not just a model change.
self._co4e_routed_provider = outcome.provider
self._append_chat("system", tr(
"routing.switched_notice",
model=outcome.model, task=outcome.task_type,
gain=f"{outcome.score_gain:.2f}"))
return outcome.model
except Exception: # noqa: BLE001 — routing must never block a Co4E turn
self._co4e_routed_provider = None
return ""
def _extract_agent_directive(self, text: str):
m = re.search(r"(?<!\S)/agent:([\w\-.]+)", text)
if not m:
return "", text
name = m.group(1)
rest = (text[:m.start()] + " " + text[m.end():]).strip()
return name, rest
def _resolve_agent(self, name: str):
low = name.lower()
for a in BUILTIN_AGENTS:
if a.slug == low or a.name.lower() == low:
return (f"You are the {a.role} agent — {a.name}.\n{a.instructions}", "")
for ca in co4e.list_custom_agents():
if co4e.slugify(ca.name) == low or ca.name.lower() == low:
return (f"You are the {ca.role} agent — {ca.name}.\n{ca.instructions}", ca.model)
return None
def _run_chat_turn(self, system_parts: List[str], request: str, model: str) -> None:
self.chat_send_btn.setEnabled(False)
log = self.chat_log # THIS flow's conversation (captured)
log._co4e_plan_bubble = None # a fresh plan for this turn
ctx = self.ctx
out_dir = self._out_dir()
sys_text = "\n\n".join(p for p in system_parts if p)
prompt = f"{sys_text}\n\n{request}" if sys_text else request
assistant = log.add_assistant() # stream into this live bubble
state = {"text": ""}
wf = getattr(self, "_wf", None)
wf_id = wf.id if wf is not None else None
flow_label = wf.name if wf is not None else "flow"
def job(worker: AgentWorker):
from ...core import agent_roles, usage_tracker as ut
from ...core.chat_agent import run_cowork
from ...core.co4e_runner import _usage_delta
# An Auto/Manual routing switch may target a different provider.
provider = ctx.build_provider_for(getattr(self, "_co4e_routed_provider", None), model or None)
messages = [{"role": "user", "content": prompt}]
ut.set_context("co4e", flow_label) # attribute + measure this turn's usage
ut.begin_accumulation()
base = ut.accumulated()
def _emit(ev):
if not isinstance(ev, dict):
return
t = ev.get("type")
if t == "text":
worker.emit_event({"type": "text", "delta": ev.get("delta", "")})
elif t == "plan_set":
worker.emit_event({"type": "plan_set", "steps": ev.get("steps") or []})
try:
run_cowork(provider, messages, out_dir, _emit, worker.is_cancelled,
security_config=ctx.config, agent_role=agent_roles.COWORK,
run_to_completion=True, enforce_rules=False)
usage = _usage_delta(base, ctx.config)
finally:
ut.end_accumulation()
for m in reversed(messages):
if m.get("role") == "assistant" and m.get("content"):
return {"text": str(m["content"]), "usage": usage}
return {"text": "", "usage": usage}
def on_event(ev):
if ev.get("type") == "text":
state["text"] += ev.get("delta", "")
assistant.set_markdown(state["text"])
log.scroll_to_bottom()
elif ev.get("type") == "plan_set":
self._append_plan(ev.get("steps") or [], log=log)
def done(result: dict):
self._chat_worker = None
self.chat_send_btn.setEnabled(True)
final = result.get("text") or state["text"]
assistant.set_markdown(final or "(no output)")
self._apply_usage(assistant, wf_id, result.get("usage"))
log.scroll_to_bottom()
def failed(err: str):
self._chat_worker = None
self.chat_send_btn.setEnabled(True)
self._append_chat("error", f"[error: {err}]", log=log)
w = AgentWorker(job)
w.event.connect(on_event)
w.finished_ok.connect(done)
w.failed.connect(failed)
self._chat_worker = w
w.start()
def _append_chat(self, role: str, text: str, log: "ChatView" = None) -> None:
"""Add one message bubble to a flow's conversation. ``log`` defaults to the
active flow's log; a run/stream passes its OWN captured log so events land
in the right flow even if the user switches tabs mid-run."""
log = log or self.chat_log
if role == "user":
bub = log.add_user(text)
elif role == "assistant":
bub = log.add_assistant()
bub.set_markdown(text)
elif role == "error":
bub = log.add_error(text)
else: # system status marker
bub = log.add_status(text)
log.scroll_to_bottom()
return bub
def _fmt_usage(self, d_in: int, d_out: int, d_cache: int, cost_usd: float) -> str:
"""The Cowork-style footer string: ↓in ↑out ▤(in+out+cache) $cost, priced
with the Monitoring model-price table in the app's display currency."""
from ...core import model_pricing as mp, usage_tracker as ut
pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
return (f"↓{mp.format_tokens(d_in)} ↑{mp.format_tokens(d_out)} "
f"▤{mp.format_tokens(d_in + d_out + d_cache)} "
f"{ut.format_cost(cost_usd, pricing)}")
def _apply_usage(self, bub, wf_id, usage) -> None:
"""Attach a token/cost footer to a step's bubble and add it to the flow's
running total (mirrors Cowork's per-message + conversation-total display)."""
if not isinstance(usage, dict):
return
d_in = int(usage.get("in", 0) or 0)
d_out = int(usage.get("out", 0) or 0)
d_cache = int(usage.get("cache", 0) or 0)
cost = float(usage.get("cost_usd", 0.0) or 0.0)
if bub is not None and (d_in or d_out):
try:
bub.add_usage(self._fmt_usage(d_in, d_out, d_cache, cost))
except Exception: # noqa: BLE001 - a usage footer must never break the run
pass
if wf_id is not None:
tot = self._flow_usage.setdefault(wf_id, {"in": 0, "out": 0, "cache": 0, "cost": 0.0})
tot["in"] += d_in; tot["out"] += d_out; tot["cache"] += d_cache; tot["cost"] += cost
self._refresh_usage_total(wf_id)
def _refresh_usage_total(self, only_wf: str = None) -> None:
"""Update the bottom conversation total to the CURRENT flow's running
usage (skip if the event is for a different, background flow)."""
lbl = getattr(self, "_usage_total_lbl", None)
if lbl is None:
return
wf = getattr(self, "_wf", None)
wf_id = wf.id if wf is not None else None
if only_wf is not None and only_wf != wf_id:
return
tot = self._flow_usage.get(wf_id) if wf_id else None
if not tot or not (tot["in"] or tot["out"]):
lbl.setText("")
return
lbl.setText(self._fmt_usage(int(tot["in"]), int(tot["out"]),
int(tot["cache"]), float(tot["cost"])))
def _append_diff(self, title: str, diff: str, log: "ChatView" = None) -> None:
"""Render a before/after diff as a collapsible colored diff bubble."""
log = log or self.chat_log
log.add_diff(f"▤ {title}", diff)
log.scroll_to_bottom()
def _append_plan(self, steps, log: "ChatView" = None) -> None:
"""Show the plan INLINE in the conversation as an expandable block; update
the same (per-flow) bubble in place so steps tick off (✓) as they complete."""
log = log or self.chat_log
body = _fmt_plan(steps)
if not body:
return
if getattr(log, "_co4e_plan_bubble", None) is None:
log._co4e_plan_bubble = log.add_plan(body)
else:
log._co4e_plan_bubble.set_plain(body)
log.scroll_to_bottom()
+180
View File
@@ -0,0 +1,180 @@
"""Dải tab các flow đang mở — R08-T09.
Mỗi flow người dùng mở là một tab. Đóng tab không đóng flow: nó chỉ rời khỏi
dải, flow vẫn còn trong thư viện bên trái.
"""
from __future__ import annotations
import re
from typing import Dict, List, Optional
from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtWidgets import QPushButton, QTabBar
from ...core import co4e
from ...i18n import tr
from ...ui.icons import icon
class Co4EFlowTabsMixin:
def _open_flow(self, wf: co4e.Workflow) -> None:
"""Open ``wf`` in a tab — reuse its tab if already open (like a browser),
else add a new one and switch to it. Bar index 0 is the pinned Runs tab,
so flow ``i`` lives at bar index ``i + 1``. If the flow has an active run,
its live status is reflected on the canvas."""
for i, f in enumerate(self._flows):
if f.id == wf.id:
self._flows[i] = wf
bar_idx = i + 1
self.flow_bar.setTabText(bar_idx, wf.name or tr("co4e.untitled"))
if self.flow_bar.currentIndex() == bar_idx:
self._active_flow_idx = -1 # force reload of same tab
self._on_flow_tab_changed(bar_idx)
else:
self.flow_bar.setCurrentIndex(bar_idx)
self._reflect_active_run(wf.id)
return
# Without the strip there is nowhere to switch between open flows, so
# opening one REPLACES the one on the canvas (saved first, as the tab
# switch used to do). Runs already in progress are unaffected — they are
# tracked per flow id and keep going in the background.
self._close_other_flows()
self._flows.append(wf)
self.flow_bar.blockSignals(True)
bar_idx = self.flow_bar.addTab(icon("flow"), wf.name or tr("co4e.untitled"))
self._add_tab_close_button(bar_idx)
self.flow_bar.blockSignals(False)
if self.flow_bar.currentIndex() == bar_idx:
self._on_flow_tab_changed(bar_idx) # already current → load manually
else:
self.flow_bar.setCurrentIndex(bar_idx)
self._reflect_active_run(wf.id)
def _close_other_flows(self) -> None:
"""Leave the canvas empty of flows, saving whatever was on it.
Called before opening a flow, because the tab strip that used to hold
several at once is gone. Tab 0 (Runs) is never touched.
"""
if not self._flows:
return
if 0 <= self._active_flow_idx < len(self._flows):
self._sync_wf_from_canvas()
self.flow_bar.blockSignals(True)
for idx in range(self.flow_bar.count() - 1, 0, -1):
self.flow_bar.removeTab(idx)
self.flow_bar.blockSignals(False)
self._flows.clear()
self._active_flow_idx = -1
def _show_runs(self, on: bool) -> None:
"""Swap the centre between the flow editor and the Runs table.
This is where the pinned "Runs" tab went when the strip was removed —
same page, same table, reached from a toggle in the flow toolbar.
"""
target = 0 if on else min(1, self.flow_bar.count() - 1)
if self.flow_bar.currentIndex() == target:
self._on_flow_tab_changed(target) # already there → re-apply
else:
self.flow_bar.setCurrentIndex(target)
def _on_flow_tab_changed(self, idx: int) -> None:
# save the outgoing flow (active_flow_idx is a FLOWS-list index) first
if 0 <= self._active_flow_idx < len(self._flows) and (self._active_flow_idx + 1) != idx:
self._sync_wf_from_canvas()
if idx <= 0: # the Runs page
self._active_flow_idx = -1
self.center_stack.setCurrentIndex(0)
self._sync_runs_toggle(True)
self._refresh_runs()
return
flow_idx = idx - 1
if not (0 <= flow_idx < len(self._flows)):
return
self._active_flow_idx = flow_idx
self.center_stack.setCurrentIndex(1)
self._sync_runs_toggle(False)
self._apply_workflow(self._flows[flow_idx])
def _sync_runs_toggle(self, on: bool) -> None:
"""Keep the Runs toggle showing which page is up, however it got there
(a double-click in the runs table also switches pages)."""
btn = getattr(self, "runs_btn", None)
if btn is not None and btn.isChecked() != on:
blocked = btn.blockSignals(True)
btn.setChecked(on)
btn.blockSignals(blocked)
def _add_tab_close_button(self, idx: int) -> None:
"""Give a flow tab its own close button — a small ✕ placed by QTabBar on
the tab's right side, vertically centered and INSIDE the tab (reliable
across themes, unlike the CSS-positioned default which looked detached)."""
btn = QPushButton("×") # ×
btn.setObjectName("flowTabClose")
btn.setFlat(True)
btn.setFixedSize(16, 16)
btn.setCursor(Qt.PointingHandCursor)
btn.clicked.connect(lambda: self._close_flow_tab_button(btn))
self.flow_bar.setTabButton(idx, QTabBar.RightSide, btn)
def _close_flow_tab_button(self, btn) -> None:
for i in range(self.flow_bar.count()):
if self.flow_bar.tabButton(i, QTabBar.RightSide) is btn:
self._close_flow_tab(i)
return
def _close_flow_tab(self, idx: int) -> None:
if idx <= 0: # Runs tab is pinned
return
flow_idx = idx - 1
if not (0 <= flow_idx < len(self._flows)):
return
closing = self._flows[flow_idx]
# Stop mirroring the closed flow's run onto the canvas — the run itself
# keeps going in the background and stays in Flow Status. (Per-flow run
# tracking: only this flow's entry is dropped; other flows keep running.)
rid = self._flow_runs.pop(closing.id, None)
if rid is not None:
self._run_logs.pop(rid, None)
if getattr(self, "_wf", None) is not None and self._wf.id == closing.id:
self._manual_active = False
self.run_btn.setText(tr("co4e.run"))
self._flows.pop(flow_idx)
self.flow_bar.blockSignals(True)
self.flow_bar.removeTab(idx)
self.flow_bar.blockSignals(False)
self._active_flow_idx = -1
if not self._flows:
self._open_flow(co4e.new_workflow(tr("co4e.untitled")))
else:
new_bar = min(idx, len(self._flows)) # clamp to the last flow tab
self.flow_bar.blockSignals(True)
self.flow_bar.setCurrentIndex(new_bar)
self.flow_bar.blockSignals(False)
self._on_flow_tab_changed(new_bar)
def _sync_active_flow_tab_text(self) -> None:
i = self.flow_bar.currentIndex()
if i >= 1: # never rename the Runs tab
self.flow_bar.setTabText(i, self._wf.name or tr("co4e.untitled"))
def _reflect_active_run(self, wf_id: str) -> None:
"""If a run for this flow is active, mirror its live node statuses onto the
canvas and keep tracking it so updates continue to show."""
for h in self.manager.all_runs():
if h.wf_id == wf_id and h.running:
self._flow_runs[wf_id] = h.id
for nid, st in h.node_status.items():
self.canvas.update_node_status(nid, st)
return
def _cur_run_id(self) -> Optional[str]:
"""The active canvas run of the CURRENTLY-shown flow, or None. Clears a
stale entry if that run already finished."""
wf = getattr(self, "_wf", None)
if wf is None:
return None
rid = self._flow_runs.get(wf.id)
if rid is None:
return None
h = self.manager.get(rid)
if h is None or not h.running:
self._flow_runs.pop(wf.id, None)
return None
return rid
def _outputs_for(self, wf_id: str) -> Dict[str, str]:
"""This flow's accumulated step outputs (kept separate per flow so parallel
runs never seed each other's context)."""
return self._flow_outputs.setdefault(wf_id, {})
def _update_run_btn(self) -> None:
self.run_btn.setText(tr("co4e.interrupt") if self._cur_run_id() is not None
else tr("co4e.run"))
+308
View File
@@ -0,0 +1,308 @@
"""Bố cục ba khung và bảng cấu hình node — R08-T09.
Co4E có bốn lớp điều hướng chồng nhau (dải flow, tab icon bên phải, bảng cấu
hình, canvas). Phần quyết định cái nào hiện lúc nào nằm ở đây, tách khỏi phần
hành vi để sửa bố cục không phải đọc logic chạy flow.
``_apply_narrow_layout`` là chỗ đáng chú ý: màn hẹp thì bảng cấu hình chuyển
từ khung cố định sang lớp phủ, vì ba khung cạnh nhau không vừa 1280px.
"""
from __future__ import annotations
import re
from typing import List
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QComboBox, QFrame, QHBoxLayout, QLabel, QLineEdit, QPushButton, QScrollArea, QSizePolicy, QSpacerItem, QSplitter, QTabBar, QTabWidget, QVBoxLayout, QWidget
from ...core import co4e
from ...i18n import tr
from ...theme import current_palette
from ...ui.co4e_canvas import Co4ECanvas
from ...ui.icons import icon
from ...presentation.co4e.co4e_run_control_widget import RunsPagePanel
class Co4ELayoutMixin:
def _build_center(self) -> QWidget:
from PySide6.QtWidgets import QStackedWidget, QTabBar
page = QWidget()
lay = QVBoxLayout(page)
# Flow tab bar: a pinned "Runs" tab first (manage every flow run), then a
# browser-style tab per open flow — each keeps its own graph (no mixing).
self.flow_bar = QTabBar()
self.flow_bar.setObjectName("flowTabs")
self.flow_bar.setTabsClosable(True)
self.flow_bar.setMovable(True)
self.flow_bar.setExpanding(False)
self.flow_bar.setDrawBase(False)
# No arrow scroll buttons — when the tabs overflow they scroll inside a
# frameless horizontal scroller you drag left/right (see flow_row below).
self.flow_bar.setUsesScrollButtons(False)
# Tab colours + layout live in theme.py (QTabBar#flowTabs — theme-aware,
# flush, centred). Here we only style the per-tab close (✕) button, which
# QTabBar places centred on the tab's right (see _add_tab_close_button).
_fp = current_palette()
self.flow_bar.setStyleSheet(
"QPushButton#flowTabClose {"
f" border: none; background: transparent; color: {_fp.text_muted};"
" font-size: 13px; font-weight: bold; padding: 0; margin: 0;"
f" border-radius: {_fp.radius_sm}px; }}"
"QPushButton#flowTabClose:hover {"
f" background: {_fp.danger_soft}; color: {_fp.danger}; }}")
runs_idx = self.flow_bar.addTab(icon("monitoring"), tr("co4e.runs_tab")) # 0 = Runs
self.flow_bar.setTabButton(runs_idx, QTabBar.RightSide, None) # pinned
self.flow_bar.currentChanged.connect(self._on_flow_tab_changed)
self.flow_bar.tabCloseRequested.connect(self._close_flow_tab)
# "+" new-flow button styled as the last tab in the strip (browser-style)
# — the + glyph sits inside a tab-shaped button flush with the tabs.
self.flow_add_btn = QPushButton("+")
self.flow_add_btn.setObjectName("flowAddBtn")
self.flow_add_btn.setFixedWidth(34)
self.flow_add_btn.setToolTip(tr("co4e.tt_new_wf"))
self.flow_add_btn.clicked.connect(self._new_workflow)
# Frameless horizontal scroller around the tab strip: overflowing tabs
# scroll (drag) left/right instead of being boxed with arrow buttons.
# The tab bar AND the "+" button are pinned to the SAME fixed height —
# giving the scroll area extra height for its scrollbar (as a previous
# version did) left the tabs top-anchored inside a taller box while the
# "+" button centered across that whole (taller) box, so the two drifted
# out of alignment. Same height on both = always aligned, no centering
# math needed; the scrollbar only appears on overflow (rare) and briefly
# overlaps the tab strip's bottom edge in that case.
_tab_h = self.flow_bar.sizeHint().height()
self.flow_bar.setFixedHeight(_tab_h)
self.flow_add_btn.setFixedHeight(_tab_h)
self.flow_scroll = QScrollArea()
self.flow_scroll.setObjectName("flowTabScroll")
self.flow_scroll.setWidget(self.flow_bar)
self.flow_scroll.setWidgetResizable(True)
self.flow_scroll.setFrameShape(QScrollArea.NoFrame) # no outer frame
self.flow_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.flow_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.flow_scroll.setFixedHeight(_tab_h)
self.flow_scroll.setStyleSheet(
"QScrollArea#flowTabScroll { background: transparent; border: none; }"
"QScrollArea#flowTabScroll QScrollBar:horizontal { height: 8px; background: transparent; margin: 0; }"
"QScrollArea#flowTabScroll QScrollBar::handle:horizontal {"
f" background: {current_palette().scroll_handle}; border-radius: 4px; min-width: 30px; }}"
"QScrollArea#flowTabScroll QScrollBar::add-line:horizontal,"
"QScrollArea#flowTabScroll QScrollBar::sub-line:horizontal { width: 0; height: 0; }")
# The strip itself is NOT shown any more (see class docstring): flows are
# picked from the WORKFLOWS list on the left, one open at a time. The
# QTabBar stays alive off-screen as the index that maps flow ↔ canvas —
# every open/close/rename path already goes through it — but the user
# never sees or drives it.
self.flow_scroll.setVisible(False)
self.flow_add_btn.setVisible(False)
# Content switches between the Runs table (tab 0) and the flow editor.
self.center_stack = QStackedWidget()
lay.addWidget(self.center_stack, 1)
self.center_stack.addWidget(self._build_runs_page()) # stack 0 = Runs
flow_page = QWidget()
lay = QVBoxLayout(flow_page)
lay.setContentsMargins(0, 0, 0, 0)
bar = QHBoxLayout(); bar.setSpacing(5)
self.name_edit = QLineEdit(self._wf.name)
self.name_edit.setToolTip(tr("co4e.tt_flow_name"))
self.name_edit.textChanged.connect(self._on_name_changed)
# "Add" is a labelled button (not a "+" icon) so it isn't mistaken for
# the zoom-in control, which now lives in the canvas's bottom-left overlay.
self.add_step_btn = QPushButton(tr("co4e.add")); self.add_step_btn.setIcon(icon("plus"))
self.add_step_btn.setToolTip(tr("co4e.tt_add_step"))
self.add_step_btn.clicked.connect(self._add_blank_step)
self.save_btn = QPushButton(tr("co4e.save")); self.save_btn.setIcon(icon("save"))
self.save_btn.setObjectName("primary")
self.save_btn.setToolTip(tr("co4e.tt_save"))
self.save_btn.clicked.connect(lambda: self._save(as_template=False))
self.save_tpl_btn = self._icon_btn("star", "co4e.tt_save_template",
lambda: self._save(as_template=True))
self.mode_combo = QComboBox()
self.mode_combo.setToolTip(tr("co4e.tt_mode"))
for m in co4e.RUN_MODES:
self.mode_combo.addItem(tr(f"co4e.mode.{m}"), m)
self.mode_combo.currentIndexChanged.connect(self._on_mode_changed)
self.run_btn = QPushButton(tr("co4e.run")); self.run_btn.setIcon(icon("play"))
self.run_btn.setObjectName("primary")
self.run_btn.setToolTip(tr("co4e.tt_run"))
self.run_btn.clicked.connect(self._on_run_clicked)
# The pinned "Runs" tab lost its strip, so it becomes a toggle here —
# one click to the run table and one click back, from either page.
self.runs_btn = QPushButton(tr("co4e.runs_tab"))
self.runs_btn.setIcon(icon("monitoring"))
self.runs_btn.setCheckable(True)
self.runs_btn.setToolTip(tr("co4e.tt_runs_tab"))
self.runs_btn.toggled.connect(self._show_runs)
bar.addWidget(QLabel(tr("co4e.flow_name")))
bar.addWidget(self.name_edit, 1)
bar.addWidget(self.add_step_btn)
bar.addWidget(self.save_btn)
bar.addWidget(self.save_tpl_btn)
bar.addWidget(self.mode_combo)
bar.addWidget(self.run_btn)
bar.addWidget(self.runs_btn)
lay.addLayout(bar)
self.canvas = Co4ECanvas()
self._build_canvas_overlay()
vsplit = QSplitter(Qt.Vertical)
vsplit.addWidget(self.canvas)
chat_widget = self._build_chat() # default-collapsed (see _build_chat)
vsplit.addWidget(chat_widget)
vsplit.setStretchFactor(0, 1)
self._vsplit = vsplit # so the message panel can collapse/expand
# Messages start collapsed — give the canvas the room from the start,
# not the [540, 220] split that assumed an expanded chat box.
collapsed_h = chat_widget.maximumHeight()
vsplit.setSizes([max(0, 760 - collapsed_h), collapsed_h])
lay.addWidget(vsplit, 1)
self.center_stack.addWidget(flow_page) # stack 1 = flow editor
self.center_stack.setCurrentIndex(1)
return page
def _build_runs_page(self) -> QWidget:
"""The pinned 'Runs' tab: a table of every flow run (name · status · steps
done/total · creator · created) for tracking. Double-click a run to open
that flow's tab with its live status.
Widget construction lives in ``RunsPagePanel`` (presentation/co4e/
co4e_run_control_widget.py); this method just wires the panel's public
attributes to the handler methods that know about ``self`` (``_show_runs``,
``_stop_selected_run``, ...) — the panel itself stays ignorant of ``Co4ETab``.
"""
panel = RunsPagePanel()
self.runs_back_btn = panel.back_btn
self.runs_back_btn.clicked.connect(lambda: self._show_runs(False))
self.runs_title = panel.title_label
self.ws_folder_btn = panel.ws_folder_btn
self.ws_folder_btn.clicked.connect(self._open_workspace_folder)
self._refresh_ws_folder_btn()
self.run_stop_btn = panel.stop_btn
self.run_stop_btn.clicked.connect(self._stop_selected_run)
self.run_rename_btn = panel.rename_btn
self.run_rename_btn.clicked.connect(self._rename_selected_run)
self.run_del_btn = panel.del_btn
self.run_del_btn.clicked.connect(self._delete_selected_run)
self.run_clear_btn = panel.clear_btn
self.run_clear_btn.clicked.connect(lambda: self.manager.clear_finished())
self.runs_table = panel.table
self.runs_table.itemDoubleClicked.connect(self._open_run_from_table)
self.runs_table.customContextMenuRequested.connect(self._runs_context_menu)
return panel
def _wrap_config(self) -> QWidget:
"""Wrap the step-config panel with a header that has an expand/collapse
toggle, so it can be folded away to give the canvas more room."""
container = QWidget()
container.setObjectName("configContainer")
v = QVBoxLayout(container)
v.setContentsMargins(0, 0, 0, 0)
v.setSpacing(0)
header = QWidget()
hb = QHBoxLayout(header)
hb.setContentsMargins(4, 3, 4, 3)
hb.setSpacing(4)
self.config_toggle_btn = QPushButton()
self.config_toggle_btn.setIcon(icon("chevron-right"))
self.config_toggle_btn.setToolTip(tr("co4e.tt_collapse_config"))
self.config_toggle_btn.setFixedSize(26, 24)
self.config_toggle_btn.clicked.connect(self._toggle_config)
self.config_title = QLabel(tr("co4e.config_title"))
self.config_title.setObjectName("hint")
hb.addWidget(self.config_toggle_btn)
hb.addWidget(self.config_title, 1)
v.addWidget(header)
v.addWidget(self.config, 1)
self._cfg_vlayout = v
# Spacers used ONLY while collapsed, to keep the lone toggle icon
# vertically CENTERED in the thin strip (its position no longer jumps to
# the top after collapsing).
self._cfg_top_spacer = QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding)
self._cfg_bot_spacer = QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding)
self.config_container = container
return container
def _apply_narrow_layout(self, narrow: bool) -> None: # noqa: D401
"""Fold the step-config panel on a narrow window, restore it when there
is room again.
Attached from __init__ rather than only on show: this page sits inside a
QTabWidget, whose minimum width is the MAXIMUM over all its pages —
including hidden ones. While Co4E sat unfolded in the background it was
forcing Project and Cowork to be ~1180px wide too.
"""
if narrow != self._config_collapsed:
self._toggle_config()
def _toggle_config(self) -> None:
self._config_collapsed = not self._config_collapsed
v = self._cfg_vlayout
if self._config_collapsed:
w = self.config_container.width()
if w > 60:
self._config_expanded_w = w
self.config.hide()
self.config_title.hide()
self.config_container.setMaximumWidth(34)
self.config_toggle_btn.setIcon(icon("chevron-left"))
self.config_toggle_btn.setToolTip(tr("co4e.tt_expand_config"))
# center the toggle vertically in the collapsed strip
v.insertItem(0, self._cfg_top_spacer)
v.addItem(self._cfg_bot_spacer)
# A maximumWidth alone doesn't make the splitter hand the freed width
# to the canvas — set sizes explicitly so the panel folds to the right.
sizes = self._split.sizes()
if len(sizes) == 3:
freed = sizes[2] - 34
sizes[2] = 34
sizes[1] = max(200, sizes[1] + freed)
self._split.setSizes(sizes)
# Without this the splitter keeps reporting the OLD minimum width,
# and since a QTabWidget's minimum is the maximum over all its pages
# — hidden ones included — Co4E would go on forcing Project and
# Cowork to be 1180px wide even while folded here.
self._refresh_min_width()
else:
v.removeItem(self._cfg_top_spacer)
v.removeItem(self._cfg_bot_spacer)
self.config_container.setMaximumWidth(16777215)
self.config.show()
self.config_title.show()
self.config_toggle_btn.setIcon(icon("chevron-right"))
self.config_toggle_btn.setToolTip(tr("co4e.tt_collapse_config"))
sizes = self._split.sizes()
if len(sizes) == 3:
want = self._config_expanded_w
delta = want - sizes[2]
sizes[2] = want
sizes[1] = max(200, sizes[1] - delta)
self._split.setSizes(sizes)
self._refresh_min_width()
def _refresh_min_width(self) -> None:
"""Make the splitter (and everything above it) re-read its minimum."""
self.config_container.updateGeometry()
self._split.refresh()
self._split.updateGeometry()
self.updateGeometry()
def _build_canvas_overlay(self) -> None:
"""Zoom +/− and Fit as a small floating control at the canvas's
bottom-left, stacked vertically. The frame is transparent (so it follows
the dark/light theme — only the buttons carry a themed background) and the
buttons are half-size."""
from PySide6.QtCore import QSize
bar = QFrame()
bar.setObjectName("canvasOverlay")
bar.setStyleSheet("QFrame#canvasOverlay { background: transparent; border: none; }")
v = QVBoxLayout(bar)
v.setContentsMargins(2, 2, 2, 2)
v.setSpacing(3)
self.zoom_in_btn = self._icon_btn("plus", "co4e.tt_zoom_in", lambda: self.canvas.zoom_in())
self.zoom_out_btn = self._icon_btn("minus", "co4e.tt_zoom_out", lambda: self.canvas.zoom_out())
self.fit_btn = self._icon_btn("search", "co4e.fit_tooltip", lambda: self.canvas.fit_view())
for b in (self.zoom_in_btn, self.zoom_out_btn, self.fit_btn):
b.setFixedSize(16, 16) # ~half the previous size
b.setIconSize(QSize(11, 11))
b.setStyleSheet("QPushButton { padding: 0px; }") # keep themed bg, drop padding
v.addWidget(b)
self.canvas.add_overlay(bar)
+364
View File
@@ -0,0 +1,364 @@
"""Chạy flow và bảng lịch sử lượt chạy — R08-T09.
Ba chế độ chạy: cả flow, một node, hoặc từng bước thủ công. ``_topo_order`` và
``_downstream`` là phần đồ thị — chạy node nào trước, node nào phụ thuộc node
nào.
``_on_manager_event`` là nơi mọi tín hiệu từ bộ chạy nền đổ về; nó dài vì phải
phân nhánh theo loại sự kiện, không tách nhỏ được mà không làm khó đọc hơn.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Dict, List, Optional
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QInputDialog, QMenu, QMessageBox, QTableWidget, QTableWidgetItem
from ...core import co4e
from ...i18n import tr
from ...theme import current_palette
class Co4ERunsMixin:
def _current_mode(self) -> str:
return self.mode_combo.currentData() or "auto"
def _on_mode_changed(self, *_a) -> None:
# switching mode resets any in-progress manual sequence
self._manual_active = False
self._manual_order = []
self._manual_idx = 0
if self._cur_run_id() is None:
self.run_btn.setText(tr("co4e.run"))
def _on_run_clicked(self) -> None:
# THIS flow's run is active → interrupt it (other flows keep running).
cur = self._cur_run_id()
if cur is not None:
self.manager.stop(cur)
return
mode = self._current_mode()
if mode == "manual":
self._manual_run_or_advance()
else:
self._start_canvas_run(plan_mode=(mode == "plan"))
def _start_canvas_run(self, *, plan_mode: bool, only: Optional[set] = None,
seed: Optional[Dict[str, str]] = None) -> None:
self._sync_wf_from_canvas()
if not self._wf.nodes:
self.status_message.emit(tr("co4e.no_steps"))
return
wf_id = self._wf.id
if only is None:
self.canvas.reset_statuses()
self._outputs_for(wf_id).clear()
self._plan_bubble = None
self._append_chat("system", tr("co4e.run_started", name=self._wf.name))
run_id = self.manager.start(
self._wf, skill_map=self._skill_map(), plan_mode=plan_mode,
only_nodes=only, seed_outputs=seed or dict(self._outputs_for(wf_id)))
self._flow_runs[wf_id] = run_id # track THIS flow's run (parallel-safe)
self._run_logs[run_id] = self.chat_log # route its events to THIS flow's log
self.run_btn.setText(tr("co4e.interrupt"))
def _run_single(self, node_id: str) -> None:
"""Run one step (config panel "Run this step") with upstream context."""
if self._cur_run_id() is not None:
return
self._start_canvas_run(plan_mode=(self._current_mode() == "plan"),
only={node_id}, seed=dict(self._outputs_for(self._wf.id)))
def _run_from(self, node_id: str) -> None:
if self._cur_run_id() is not None:
return
self._start_canvas_run(plan_mode=(self._current_mode() == "plan"),
only=self._downstream(node_id), seed=dict(self._outputs_for(self._wf.id)))
def _downstream(self, node_id: str) -> set:
adj: Dict[str, List[str]] = {}
for e in self.canvas.edges():
adj.setdefault(e.source, []).append(e.target)
seen, stack = set(), [node_id]
while stack:
cur = stack.pop()
if cur in seen:
continue
seen.add(cur)
stack.extend(adj.get(cur, []))
return seen
def _manual_run_or_advance(self) -> None:
if not self._manual_active:
self._sync_wf_from_canvas()
if not self._wf.nodes:
self.status_message.emit(tr("co4e.no_steps"))
return
self.canvas.reset_statuses()
self._outputs_for(self._wf.id).clear()
self._plan_bubble = None
self._manual_order = self._topo_order()
self._manual_idx = 0
self._manual_active = True
self._append_chat("system", tr("co4e.manual_started", name=self._wf.name))
self._manual_step()
def _manual_step(self) -> None:
if self._manual_idx >= len(self._manual_order):
self._manual_active = False
self.run_btn.setText(tr("co4e.run"))
self._append_chat("system", tr("co4e.run_done"))
return
nid = self._manual_order[self._manual_idx]
label = next((n.data.label for n in self.canvas.nodes() if n.id == nid), nid)
self._append_chat("system", tr("co4e.manual_step",
i=self._manual_idx + 1, n=len(self._manual_order), label=label))
run_id = self.manager.start(
self._wf, skill_map=self._skill_map(),
plan_mode=False, only_nodes={nid}, seed_outputs=dict(self._outputs_for(self._wf.id)),
manual=True)
self._flow_runs[self._wf.id] = run_id
self._run_logs[run_id] = self.chat_log
self.run_btn.setText(tr("co4e.interrupt"))
def _topo_order(self) -> List[str]:
nodes = self.canvas.nodes()
edges = self.canvas.edges()
waves = co4e.compute_waves(nodes, edges)
y = {n.id: n.y for n in nodes}
return sorted((n.id for n in nodes), key=lambda nid: (waves.get(nid, 0), y.get(nid, 0)))
def _on_manager_event(self, run_id: str, ev: dict) -> None:
# Per-flow routing: every run's events go to ITS OWN flow log (so parallel
# runs never mix), and the canvas mirrors ONLY the run whose flow is the
# one currently shown. Flow Status refreshes on its own via `changed`.
h = self.manager.get(run_id)
run_wf = h.wf_id if h is not None else None
log = self._run_logs.get(run_id) or self.chat_log
shown = getattr(self, "_wf", None) is not None and run_wf == self._wf.id
t = ev.get("type")
if t == "node_status":
if shown:
self.canvas.update_node_status(ev.get("node_id"), ev.get("status"))
elif t == "node_output":
if run_wf is not None:
self._outputs_for(run_wf)[ev["node_id"]] = ev.get("output", "")
label = ev["node_id"]
if shown:
label = next((n.data.label for n in self.canvas.nodes() if n.id == ev["node_id"]),
ev["node_id"])
elif h is not None and h.wf is not None:
label = next((n.data.label for n in h.wf.nodes if n.id == ev["node_id"]), ev["node_id"])
if ev.get("output"):
bub = self._append_chat("assistant", f"**{label}**\n\n{ev['output']}", log=log)
# Per-message token/cost footer (↓in ↑out ▤ctx $cost), like Cowork.
self._apply_usage(bub, run_wf, ev.get("usage"))
elif t == "node_diff":
self._append_diff(ev.get("title", ""), ev.get("diff", ""), log=log)
elif t == "node_plan":
self._append_plan(ev.get("steps") or [], log=log)
elif t == "node_tool":
if not ev.get("ok", True):
# A single failed tool call isn't a step failure — the agent is told
# to recover and continue, so show it as a neutral notice (not a red
# "Error" that reads like the whole flow crashed).
self._append_chat("system", "⚠ " + tr("co4e.tool_failed", name=ev.get("name", "")), log=log)
elif t in ("run_done", "run_error"):
# Drop THIS flow's run tracking (other flows keep running in parallel).
if run_wf is not None and self._flow_runs.get(run_wf) == run_id:
self._flow_runs.pop(run_wf, None)
self._run_logs.pop(run_id, None)
if self._manual_active and shown:
self._manual_idx += 1
self._manual_step()
else:
if shown:
self.run_btn.setText(tr("co4e.run"))
self._append_chat("system", tr("co4e.run_done"), log=log)
# Clickable link to the output folder so files are one click away.
out = (h.out_dir if h is not None and h.out_dir else "") or str(self._flow_output_root())
try:
log.add_folder_link(out, tr("co4e.open_output_link"))
log.scroll_to_bottom()
except Exception: # noqa: BLE001 - link is a nicety, never fatal
pass
self._notify_run_finished(run_id) # popup: the flow finished
if not shown and h is not None:
self.status_message.emit(tr("co4e.bg_done", name=h.name, status=h.status))
def _notify_run_finished(self, run_id: str) -> None:
"""Show a non-blocking popup when a flow finishes (done / error / stopped),
so the user is notified even if they're on another screen."""
h = self.manager.get(run_id)
if h is None:
return
from PySide6.QtWidgets import QMessageBox
if not hasattr(self, "_run_popups"):
self._run_popups = []
box = QMessageBox(self)
box.setIcon(QMessageBox.Warning if h.status == "error" else QMessageBox.Information)
box.setWindowTitle(tr("co4e.run_done_title"))
box.setText(tr("co4e.run_done_popup", name=h.name,
status=tr("co4e.status." + h.status)))
box.setStandardButtons(QMessageBox.Ok)
box.setModal(False) # non-blocking notification
box.setAttribute(Qt.WA_DeleteOnClose, True)
box.finished.connect(
lambda _r=0, b=box: self._run_popups.remove(b) if b in self._run_popups else None)
self._run_popups.append(box) # keep a ref so it isn't GC'd
box.show()
def _refresh_runs(self) -> None:
# Rebuild the always-fresh Runs table from the manager (single source of truth).
if not hasattr(self, "runs_table"):
return
p = current_palette()
color = {"running": p.accent, "done": p.success, "error": p.danger,
"stopped": p.text_muted}
dots = {"running": "▶", "done": "✓", "error": "✕", "stopped": "■"}
# Most-recent run at the TOP, oldest at the bottom (manager keeps runs in
# chronological insertion order, so reverse it for display).
runs = list(reversed(self.manager.runs()))
t = self.runs_table
# Preserve the selected run across the rebuild by its id (row indices shift
# as runs are added/deleted, so a row-index restore would jump).
sel_item = t.item(t.currentRow(), 0) if t.currentRow() >= 0 else None
sel_id = sel_item.data(Qt.UserRole) if sel_item is not None else None
t.setRowCount(len(runs))
sel_row = -1
for r, h in enumerate(runs):
vals = [f"{dots.get(h.status, '•')} {h.name}", tr("co4e.status." + h.status),
h.progress_text(), h.created_by or "-", h.created_at or "-"]
for c, val in enumerate(vals):
it = QTableWidgetItem(str(val))
if c == 0:
it.setData(Qt.UserRole, h.id)
if c == 1:
it.setForeground(_qcolor(color.get(h.status, p.text)))
t.setItem(r, c, it)
if h.id == sel_id:
sel_row = r
if sel_row >= 0:
t.setCurrentCell(sel_row, 0)
# The sidebar's short run list is the same data — refresh it together.
self._refresh_side_runs()
# Active-run count, on the sidebar heading now that the tab strip is gone.
n = self.manager.active_count()
label = tr("co4e.runs_tab_n", n=n) if n else tr("co4e.runs_tab")
if hasattr(self, "flow_bar"):
self.flow_bar.setTabText(0, label)
head = (self._sections.get("co4e.runs_tab") or (None,))[0]
if head is not None:
head.setText(("▾ " if head.isChecked() else "▸ ") + label.upper())
def _stop_selected_run(self) -> None:
row = self.runs_table.currentRow()
it = self.runs_table.item(row, 0) if row >= 0 else None
if it is None:
self.manager.stop_all()
return
self.manager.stop(it.data(Qt.UserRole))
def _delete_selected_run(self) -> None:
"""Delete the selected run from the Flow Status history (a running one is
stopped first). Removes just that single entry."""
row = self.runs_table.currentRow()
it = self.runs_table.item(row, 0) if row >= 0 else None
if it is None:
self.status_message.emit(tr("co4e.select_run"))
return
run_id = it.data(Qt.UserRole)
h = self.manager.get(run_id) # stop tracking it per-flow if we were
if h is not None and self._flow_runs.get(h.wf_id) == run_id:
self._flow_runs.pop(h.wf_id, None)
self._run_logs.pop(run_id, None)
self.manager.remove(run_id) # emits `changed` → _refresh_runs
def _runs_context_menu(self, pos) -> None:
from PySide6.QtWidgets import QMenu
item = self.runs_table.itemAt(pos)
if item is None:
return
self.runs_table.selectRow(item.row())
menu = QMenu(self)
menu.addAction(tr("co4e.open_run"),
lambda: self._open_run_from_table(self.runs_table.item(item.row(), 0)))
it0 = self.runs_table.item(item.row(), 0)
rid = it0.data(Qt.UserRole) if it0 is not None else None
menu.addAction(tr("co4e.open_output"), lambda: self._open_run_output_folder(rid))
menu.addAction(tr("co4e.rename_run"), self._rename_selected_run)
menu.addAction(tr("co4e.delete_run"), self._delete_selected_run)
menu.exec(self.runs_table.viewport().mapToGlobal(pos))
def _open_run_output_folder(self, run_id) -> None:
"""Open the workspace folder a specific run wrote its files into."""
from ...ui.osutil import open_location
h = self.manager.get(run_id) if run_id else None
path = Path(h.out_dir) if (h is not None and h.out_dir) else self._flow_output_root()
if not path.exists():
path = self._flow_output_root()
try:
path.mkdir(parents=True, exist_ok=True)
except OSError:
pass
open_location(str(path))
def _rename_selected_run(self) -> None:
"""Rename the selected run in Flow Status — updates the run entry AND its
underlying saved flow / open tab so the name stays consistent everywhere."""
row = self.runs_table.currentRow()
it = self.runs_table.item(row, 0) if row >= 0 else None
if it is None:
self.status_message.emit(tr("co4e.select_run"))
return
run_id = it.data(Qt.UserRole)
h = self.manager.get(run_id)
if h is None:
return
from PySide6.QtWidgets import QInputDialog
new, ok = QInputDialog.getText(self, tr("co4e.rename_run"),
tr("co4e.rename_run_label"), text=h.name)
new = (new or "").strip()
if not ok or not new or new == h.name:
return
self.manager.rename(run_id, new) # run entry + snapshot (→ refresh)
# Keep the underlying saved flow + any open tab in sync.
wf = co4e.get_workflow(h.wf_id)
if wf is not None:
wf.name = new
co4e.save_workflow(wf)
self._reload_sidebar()
for i, f in enumerate(self._flows):
if f.id == h.wf_id:
f.name = new
self.flow_bar.setTabText(i + 1, new)
break
if self._wf.id == h.wf_id and self.name_edit.text() != new:
self.name_edit.setText(new) # updates _wf.name + active tab text
def _run_selected_in_background(self) -> None:
wf = self._selected_wf()
if wf is None:
self.status_message.emit(tr("co4e.select_flow"))
return
self.manager.start(wf, skill_map=self._skill_map(),
plan_mode=(self._current_mode() == "plan"))
# Used to jump the sidebar back to the Workflows tab; with one column
# there is nothing to jump to — show the run that just started instead.
self._refresh_side_runs()
self.status_message.emit(tr("co4e.bg_started", name=wf.name))
def _rerun_run_item(self, item) -> None:
"""Double-click a run in the history → run that flow again (in background)."""
h = self.manager.get(item.data(Qt.UserRole))
if h is None:
return
wf = self._wf_by_id(h.wf_id)
if wf is None:
self.status_message.emit(tr("co4e.flow_gone"))
return
self.manager.start(wf, skill_map=self._skill_map(),
plan_mode=(self._current_mode() == "plan"))
self.status_message.emit(tr("co4e.bg_started", name=wf.name))
def _open_run_from_table(self, item) -> None:
"""Double-click a run row in the Runs tab → open that flow's tab and show
its live status (opens/focuses the tab; _open_flow reflects the run)."""
id_item = self.runs_table.item(item.row(), 0)
if id_item is None:
return
h = self.manager.get(id_item.data(Qt.UserRole))
if h is None:
return
# Prefer the flow the run kept a reference to (works even after its tab was
# closed or if it was never saved); fall back to resolving by id.
wf = getattr(h, "wf", None) or self._wf_by_id(h.wf_id)
if wf is None:
self.status_message.emit(tr("co4e.flow_gone"))
return
self._open_flow(wf)
# reflect this run's step statuses (done/error/running) on the canvas
for nid, st in h.node_status.items():
self.canvas.update_node_status(nid, st)
self.status_message.emit(tr("co4e.viewing_flow", name=wf.name))
+251
View File
@@ -0,0 +1,251 @@
"""Cột trái: thư viện workflow, agent, skill — R08-T09.
Bốn mục gập được (WORKFLOWS / AGENTS / SKILLS / FLOW STATUS). Trạng thái gập
của từng mục là thứ người dùng đặt rồi mong nó giữ nguyên, nên nó nằm trong
cấu hình chứ không phải trong widget.
"""
from __future__ import annotations
import re
from typing import List
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QHBoxLayout, QListWidget, QListWidgetItem, QPushButton, QSplitter, QVBoxLayout, QWidget
from ...core import co4e, skills as skills_mod
from ...i18n import tr
from ...ui.icons import icon
from ...presentation.co4e.agent_list_panel import AgentListPanel
from ...presentation.co4e.co4e_chat_view import _skill_names
from ...presentation.co4e.palette_list import _PaletteList
from ...presentation.co4e.skills_list_panel import SkillsListPanel
class Co4ESidebarMixin:
def _build_sidebar(self) -> QWidget:
# ONE COLUMN, four named sections — no icon tabs. Every list is on screen
# at once, so "what can I drag onto the canvas" is answered by looking
# rather than by clicking through three unlabeled tabs.
# A vertical splitter, not a fixed stack: on a short window four stacked
# lists otherwise squeeze down to one visible row each. The splitter
# hands out the available height by weight and lets the user re-balance
# it by dragging; each list keeps a small minimum so none disappears.
self._sections: dict = {}
self.sidebar = QWidget()
outer_col = QVBoxLayout(self.sidebar)
outer_col.setContentsMargins(6, 6, 6, 6)
outer_col.setSpacing(0)
self.side_split = QSplitter(Qt.Vertical)
self.side_split.setChildrenCollapsible(False)
self.side_split.setHandleWidth(8)
outer_col.addWidget(self.side_split, 1)
class _Col:
"""Adapter so the section builders below read the same as before."""
def __init__(self, split):
self._split = split
def addWidget(self, w, stretch=1):
self._split.addWidget(w)
self._split.setStretchFactor(self._split.count() - 1, stretch)
col = _Col(self.side_split)
# --- WORKFLOWS ---------------------------------------------------
self.wf_new_btn = QPushButton(tr("co4e.new"))
self.wf_new_btn.setIcon(icon("plus"))
self.wf_new_btn.setToolTip(tr("co4e.tt_new_wf"))
self.wf_new_btn.setObjectName("co4eSectionAction")
self.wf_new_btn.setFlat(True)
self.wf_new_btn.setCursor(Qt.PointingHandCursor)
self.wf_new_btn.clicked.connect(self._new_workflow)
wf_body = QWidget(); wl = QVBoxLayout(wf_body)
wl.setContentsMargins(0, 0, 0, 0); wl.setSpacing(4)
# Draggable: drag a flow onto the canvas to merge it in (Nova-style);
# double-click loads it onto the canvas.
self.wf_list = _PaletteList(payload_role=Qt.UserRole + 2)
self.wf_list.setToolTip(tr("co4e.drag_hint"))
self.wf_list.itemDoubleClicked.connect(self._load_selected_workflow)
self.wf_list.setContextMenuPolicy(Qt.CustomContextMenu)
self.wf_list.customContextMenuRequested.connect(self._wf_context_menu)
wl.addWidget(self.wf_list, 1)
wf_btns = QHBoxLayout(); wf_btns.setSpacing(4)
self.wf_edit_btn = self._icon_btn("edit", "co4e.tt_edit_wf", self._edit_selected_workflow)
self.wf_dup_btn = self._icon_btn("branch", "co4e.tt_dup_wf", self._duplicate_selected_workflow)
self.wf_del_btn = self._icon_btn("trash", "co4e.tt_del_wf", self._delete_selected_workflow)
for b in (self.wf_edit_btn, self.wf_dup_btn, self.wf_del_btn):
wf_btns.addWidget(b)
wf_btns.addStretch(1)
wl.addLayout(wf_btns)
# Its own row: sharing one line with the three icon buttons cut "Chạy
# nền" down to "Chạ" as soon as the sidebar hit its narrow width.
self.wf_runbg_btn = QPushButton(tr("co4e.run_bg")); self.wf_runbg_btn.setIcon(icon("play"))
self.wf_runbg_btn.setToolTip(tr("co4e.tt_run_bg"))
self.wf_runbg_btn.clicked.connect(self._run_selected_in_background)
wl.addWidget(self.wf_runbg_btn)
col.addWidget(self._section("co4e.tab_workflows", wf_body, self.wf_new_btn), 3)
# --- AGENTS ------------------------------------------------------
# Widget cua khu vuc nay da doi sang AgentListPanel (xem
# presentation/co4e/agent_list_panel.py); o day chi con giu
# ag_new_btn/agent_list/ag_edit_btn/ag_del_btn nhu 4 ten thuoc tinh cu
# va tu noi signal - dung nguyen tac panel khong tu wire, Co4ETab moi
# biet _new_agent/_edit_agent/_delete_agent.
self._agent_panel = AgentListPanel()
self.ag_new_btn = self._agent_panel.new_btn
self.ag_new_btn.clicked.connect(self._new_agent)
self.agent_list = self._agent_panel.list_widget
self.ag_edit_btn = self._agent_panel.edit_btn
self.ag_edit_btn.clicked.connect(self._edit_agent)
self.ag_del_btn = self._agent_panel.del_btn
self.ag_del_btn.clicked.connect(self._delete_agent)
col.addWidget(self._section("co4e.tab_agents", self._agent_panel, self.ag_new_btn), 3)
# --- SKILLS ------------------------------------------------------
# Widget cua khu vuc nay da doi sang SkillsListPanel (xem
# presentation/co4e/skills_list_panel.py); o day chi con giu
# sk_manage_btn/skill_list nhu 2 ten thuoc tinh cu va tu noi signal -
# dung nguyen tac panel khong tu wire, Co4ETab moi biet _manage_skills.
self._skills_panel = SkillsListPanel()
self.sk_manage_btn = self._skills_panel.manage_btn
self.sk_manage_btn.clicked.connect(self._manage_skills)
self.skill_list = self._skills_panel.list_widget
col.addWidget(self._section("co4e.tab_skills", self._skills_panel, self.sk_manage_btn), 2)
# --- RUNS --------------------------------------------------------
# A short, always-visible view of the same runs the Flow Status page
# tables in full. Clicking one opens that page with the run selected.
# Icon only: the heading beside it already reads FLOW STATUS, and the
# label was long enough to be cut in half in a narrow sidebar.
self.runs_more_btn = QPushButton()
self.runs_more_btn.setIcon(icon("chevron-right"))
self.runs_more_btn.setFixedWidth(30)
self.runs_more_btn.setFlat(True)
self.runs_more_btn.setToolTip(tr("co4e.tt_runs_tab"))
self.runs_more_btn.clicked.connect(lambda: self._show_runs(True))
runs_body = QWidget(); rl = QVBoxLayout(runs_body)
rl.setContentsMargins(0, 0, 0, 0); rl.setSpacing(4)
self.runs_side_list = QListWidget()
self.runs_side_list.setToolTip(tr("co4e.tt_runs_tab"))
self.runs_side_list.itemClicked.connect(self._on_side_run_clicked)
rl.addWidget(self.runs_side_list, 1)
col.addWidget(self._section("co4e.runs_tab", runs_body, self.runs_more_btn), 2)
# Small enough that all four still fit on a laptop screen, large enough
# that each shows more than a single row.
for lst in (self.wf_list, self.agent_list, self.skill_list, self.runs_side_list):
lst.setMinimumHeight(56)
return self.sidebar
def _refresh_side_runs(self) -> None:
"""Mirror the newest runs into the sidebar's short list."""
lst = getattr(self, "runs_side_list", None)
if lst is None:
return
dots = {"running": "▶", "done": "✓", "error": "✕", "stopped": "■"}
lst.clear()
for h in list(reversed(self.manager.runs()))[:self._SIDE_RUNS]:
it = QListWidgetItem(f"{dots.get(h.status, '•')} {h.name}"
f" {h.progress_text()}")
it.setData(Qt.UserRole, h.id)
it.setToolTip(f"{h.name} · {tr('co4e.status.' + h.status)} · {h.created_at or '-'}")
lst.addItem(it)
def _on_side_run_clicked(self, item) -> None:
"""Open the full Flow Status page with this run selected."""
run_id = item.data(Qt.UserRole)
self._show_runs(True)
for r in range(self.runs_table.rowCount()):
cell = self.runs_table.item(r, 0)
if cell is not None and cell.data(Qt.UserRole) == run_id:
self.runs_table.setCurrentCell(r, 0)
break
def _section(self, key: str, body: QWidget, action: QPushButton | None = None,
stretch: int = 1) -> QWidget:
"""One named, foldable section of the sidebar column.
Replaces the three icon-only tabs: all the lists are visible at once
(WORKFLOWS · AGENTS · SKILLS · runs), each under its own heading with the
action that belongs to it. Clicking the heading folds the section, so a
narrow window can still get to everything.
"""
box = QWidget()
v = QVBoxLayout(box)
v.setContentsMargins(0, 0, 0, 0)
v.setSpacing(2)
row = QHBoxLayout()
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
head = QPushButton()
head.setObjectName("co4eSectionHdr")
head.setCheckable(True)
head.setChecked(True)
head.setCursor(Qt.PointingHandCursor)
head.setFlat(True)
head.toggled.connect(lambda on, w=body, b=box: self._fold_section(key, w, b, on))
row.addWidget(head, 1)
if action is not None:
row.addWidget(action, 0)
v.addLayout(row)
v.addWidget(body, 1)
self._sections[key] = (head, body, stretch)
self._sync_section_arrow(key)
return box
def _fold_section(self, key: str, body: QWidget, box: QWidget, on: bool) -> None:
"""Fold/unfold a section AND give its height back to the others.
Inside a splitter, hiding the body is not enough — the pane keeps its
share of the height, so folding would free nothing. Clamping the whole
section to its header height makes the splitter re-deal the space.
"""
body.setVisible(on)
if on:
box.setMaximumHeight(16777215)
else:
box.setMaximumHeight(box.layout().itemAt(0).sizeHint().height() + 4)
self._sync_section_arrow(key)
def _sync_section_arrow(self, key: str) -> None:
head, _body, _s = self._sections[key]
head.setText(("▾ " if head.isChecked() else "▸ ") + tr(key).upper())
def _icon_btn(self, icon_name: str, tip_key: str, slot) -> QPushButton:
b = QPushButton(); b.setIcon(icon(icon_name)); b.setToolTip(tr(tip_key))
b.setFixedWidth(34)
b.clicked.connect(slot)
return b
def _reload_sidebar(self) -> None:
self.wf_list.clear()
for wf in co4e.list_workflows():
tag = tr("co4e.template") if wf.is_template else tr("co4e.saved")
it = QListWidgetItem(icon("workspaces"), f"{wf.name} · {tag}")
it.setData(Qt.UserRole, ("saved", wf.id))
it.setData(Qt.UserRole + 2, {"kind": "workflow", "workflow": co4e.workflow_to_dict(wf)})
self.wf_list.addItem(it)
# Agents: only the Parallel fan-out node + the user's own custom agents
# (create your own with "+ New agent"; drag onto the canvas). The blank
# "New Step" palette entry was removed — use the toolbar "+ Add" instead.
self.agent_list.clear()
self.agent_list.addItem(self._palette_item(
tr("co4e.parallel_node"), "server",
{"variant": "parallel", "label": "Parallel", "role": "PARALLEL", "icon": "server",
"sub_agents": []}))
for ca in co4e.list_custom_agents():
step = co4e.Step(label=ca.name, agent_slug=co4e.slugify(ca.name), role=ca.role or "AGENT",
icon=ca.icon, instructions=ca.instructions,
context=getattr(ca, "context", ""), model=ca.model,
permission_preset=ca.permission_preset, skills=list(ca.skills),
attachments=list(getattr(ca, "attachments", []) or []))
it = self._palette_item(f"{ca.name} · {ca.role} · {tr('co4e.custom')}", ca.icon or "robot",
co4e._step_dict(step))
it.setData(Qt.UserRole + 1, ca.id)
self.agent_list.addItem(it)
# Skills
self.skill_list.clear()
for name in _skill_names():
content = skills_mod.skill_prefix_for(name)
payload = co4e._step_dict(co4e.Step(
label=name, agent_slug=co4e.slugify(name), role="SKILL", icon="sparkle",
instructions=content, skills=[name]))
self.skill_list.addItem(self._palette_item(name, "sparkle", payload))
@staticmethod
def _palette_item(text: str, icon_name: str, payload: dict) -> QListWidgetItem:
it = QListWidgetItem(icon(icon_name), text)
it.setData(Qt.UserRole, payload)
return it
+154
View File
@@ -0,0 +1,154 @@
"""Tạo, sửa, đổi tên, xoá, nhân bản workflow — R08-T09.
Chỉ thao tác trên danh sách. Phần chạy một workflow nằm ở ``co4e_runs.py``,
phần vẽ node nằm ở canvas.
"""
from __future__ import annotations
import re
from typing import List, Optional
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QInputDialog, QMenu
from ...core import co4e
from ...i18n import tr
from ...ui.icons import icon
from ...presentation.co4e.co4e_chat_view import _skill_names
class Co4EWorkflowCrudMixin:
def _apply_workflow(self, wf: co4e.Workflow) -> None:
self._wf = wf
# Per-flow outputs are kept in self._flow_outputs[wf.id] — do NOT clear
# here (switching tabs must not wipe another flow's accumulated context).
# Switch the visible conversation to THIS flow's own log.
self.chat_stack.setCurrentWidget(self._ensure_flow_log(wf.id))
self.name_edit.setText(wf.name)
self.canvas.load(wf.nodes, wf.edges)
self.config.clear_step()
if wf.nodes:
self.canvas.relayout_if_vertical() # convert old top-down flows to left→right
self.canvas.fit_view()
self._update_run_btn() # reflect THIS flow's run state
self._refresh_usage_total() # show THIS flow's token/cost total
def _new_workflow(self) -> None:
self._open_flow(co4e.new_workflow(tr("co4e.untitled")))
# Pressing this while the canvas already holds an empty untitled flow
# produced an identical empty untitled flow — correct, and completely
# invisible, so the button read as broken. Say what happened and put the
# cursor where the next thing to do is: naming it.
self.name_edit.setFocus()
self.name_edit.selectAll()
self.status_message.emit(tr("co4e.new_flow_ready"))
def _selected_wf(self) -> Optional[co4e.Workflow]:
"""Materialise the selected saved-flow row into a Workflow."""
item = self.wf_list.currentItem()
if item is None:
return None
_kind, ident = item.data(Qt.UserRole)
return co4e.get_workflow(ident)
def _load_selected_workflow(self, *_a) -> None:
wf = self._selected_wf()
if wf is not None:
self._open_flow(wf) # open (or focus) its browser-style tab
def _edit_selected_workflow(self) -> None:
wf = self._selected_wf()
if wf is None:
self.status_message.emit(tr("co4e.select_flow"))
return
self._open_flow(wf)
def _duplicate_selected_workflow(self) -> None:
wf = self._selected_wf()
if wf is None:
self.status_message.emit(tr("co4e.select_flow"))
return
dup = co4e.duplicate_workflow(wf)
self._reload_sidebar()
self.status_message.emit(tr("co4e.duplicated_msg", name=dup.name))
def _wf_context_menu(self, pos) -> None:
lw = self.wf_list
item = lw.itemAt(pos)
if item is None:
return
lw.setCurrentItem(item)
_kind, ident = item.data(Qt.UserRole)
menu = QMenu(lw)
act_edit = menu.addAction(icon("edit"), tr("co4e.edit"))
act_rename = menu.addAction(icon("edit"), tr("co4e.rename"))
act_dup = menu.addAction(icon("branch"), tr("co4e.duplicate"))
act_run = menu.addAction(icon("play"), tr("co4e.run_bg"))
act_del = menu.addAction(icon("trash"), tr("co4e.delete"))
chosen = menu.exec(lw.viewport().mapToGlobal(pos))
if chosen is act_edit:
self._edit_selected_workflow()
elif chosen is act_rename:
self._rename_workflow(ident)
elif chosen is act_dup:
self._duplicate_selected_workflow()
elif chosen is act_run:
self._run_selected_in_background()
elif chosen is act_del:
self._delete_selected_workflow()
def _rename_workflow(self, ident: str) -> None:
"""Rename a saved flow in place (e.g. to match its function/task)."""
wf = co4e.get_workflow(ident)
if wf is None:
return
name, ok = QInputDialog.getText(self, tr("co4e.rename"), tr("co4e.rename_prompt"),
text=wf.name)
name = (name or "").strip()
if not ok or not name:
return
wf.name = name
co4e.save_workflow(wf)
if self._wf.id == ident:
self.name_edit.setText(name)
self._wf.name = name
self._reload_sidebar()
self.status_message.emit(tr("co4e.renamed_msg", name=name))
def _delete_selected_workflow(self) -> None:
item = self.wf_list.currentItem()
if item is None:
return
_kind, ident = item.data(Qt.UserRole)
co4e.delete_workflow(ident)
self._reload_sidebar()
def _sync_wf_from_canvas(self) -> None:
self._wf.nodes = self.canvas.nodes()
self._wf.edges = self.canvas.edges()
self._wf.name = self.name_edit.text().strip() or tr("co4e.untitled")
def _save(self, as_template: bool) -> None:
self._sync_wf_from_canvas()
self._wf.is_template = as_template
co4e.save_workflow(self._wf)
self._reload_sidebar()
self.status_message.emit(tr("co4e.saved_msg", name=self._wf.name))
def _autosave(self) -> None:
if co4e.get_workflow(self._wf.id) is not None:
self._sync_wf_from_canvas()
co4e.save_workflow(self._wf)
def _on_name_changed(self, text: str) -> None:
self._wf.name = text.strip() or tr("co4e.untitled")
self._sync_active_flow_tab_text()
def _add_blank_step(self) -> None:
self.canvas.add_palette_step(co4e.Step(label="New Step"),
self.canvas.mapToScene(self.canvas.rect().center()))
def _on_node_selected(self, node_id: str) -> None:
for n in self.canvas.nodes():
if n.id == node_id:
self.config.load_step(node_id, n.data, _skill_names())
if self._config_collapsed:
self._toggle_config()
return
def _on_config_changed(self) -> None:
for n in self.canvas.nodes():
self.canvas.refresh_node(n.id)
self._autosave()
def _wf_by_id(self, wf_id: str) -> Optional[co4e.Workflow]:
"""Resolve a flow id to a Workflow — saved, or the open canvas."""
wf = co4e.get_workflow(wf_id)
if wf is not None:
return wf
if self._wf.id == wf_id:
self._sync_wf_from_canvas()
return self._wf
return None
+2 -1
View File
@@ -43,7 +43,8 @@ MUTATIONS = [
"self.section_list, self.section_stack = section_panels(pages[:1])", "self.section_list, self.section_stack = section_panels(pages[:1])",
"check_dialogs.py"), "check_dialogs.py"),
("noi lai dai tab flow Co4E", ("noi lai dai tab flow Co4E",
"ui/co4e_tab.py", # R08-T09 doi cho: Co4ETab tach thanh 7 mixin duoi presentation/co4e/.
"presentation/co4e/co4e_layout.py",
"self.flow_scroll.setVisible(False)", "self.flow_scroll.setVisible(False)",
"self.flow_scroll.setVisible(True)", "self.flow_scroll.setVisible(True)",
"check_co4e.py"), "check_co4e.py"),
+18 -1514
View File
File diff suppressed because it is too large Load Diff