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:
co-authored by
Claude Opus 5
parent
70a0c2fdcf
commit
0e00bf3c2f
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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"))
|
||||||
@@ -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)
|
||||||
@@ -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))
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user