Trước đây rất nhiều widget gọi setText(tr(...)) một lần lúc dựng, nên sau khi đổi ngôn ngữ một nửa màn hình vẫn giữ tiếng cũ. Thêm họ bind_text/bind_tip/ bind_placeholder/bind_items/bind_dynamic trong i18n: khoá dịch được gắn thẳng vào widget (giữ tham chiếu yếu) và tự áp lại mỗi lần set_language. - co4e sidebar, node property panel, run control, routing toggle, nav rail, top bar, filter scaffold, usage chart: chuyển sang binding hoặc tự đăng ký on_language_changed thay vì trông chờ nơi nhúng. - RoutingToggle/AutoRunToggle tự đăng ký retranslate và dùng AdjustToContents để bản dịch dài không bị cắt. - BusyOverlay mới: che cửa sổ trong lúc set_language chạy đồng bộ trên GUI thread, tránh cảm giác treo app. - co4e sidebar chỉ đọc thư viện skill một lần (_skill_prefix_lookup) thay vì quét đĩa cho từng skill — trước đó mỗi lần reload tốn ~3.8s đứng GUI. - Bổ sung bản dịch ja/vi còn để nguyên tiếng Anh; sửa chiều cao hàng Settings ở nav rail. - Thêm 4 bộ test UI cho các thay đổi trên. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
441 lines
17 KiB
Python
441 lines
17 KiB
Python
"""Co4E — node-graph workflow studio (a Workspace sub-tab).
|
|
|
|
Layout (3 columns): left sidebar (Workflows / Agents / Skills, with CRUD,
|
|
drag-to-canvas, run-in-background + a live "Running flows" status list) | center
|
|
(compact toolbar + node canvas + bottom Chat/Output) | right (step config panel).
|
|
|
|
Runs: pick a mode — Auto (each step's agent plans then executes), Plan
|
|
(read-only, each step only drafts a plan), or Manual (step-by-step, advance with
|
|
"Next step"). Adjacent steps feed the next automatically (no manual wiring).
|
|
Several flows can run at once (foreground on the canvas + background from the
|
|
Workflows list); the run manager keeps their status live across sub-tab switches.
|
|
|
|
Chat supports inline directives with autocomplete: ``/agent:<name>`` picks a
|
|
persona and ``/skill:<name>`` applies a skill — same as Cowork.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
|
|
from PySide6.QtCore import QSize, Qt, Signal
|
|
from PySide6.QtWidgets import (
|
|
QComboBox, QFrame, QHBoxLayout, QHeaderView, QInputDialog, QLabel, QLineEdit,
|
|
QListView, QListWidget, QListWidgetItem, QMenu, QMessageBox, QPushButton,
|
|
QScrollArea, QSizePolicy, QSpacerItem, QSplitter, QTabBar, QTableWidget,
|
|
QTableWidgetItem, QTabWidget, QTextBrowser, QVBoxLayout, QWidget,
|
|
)
|
|
|
|
from ..core import co4e, skills as skills_mod
|
|
from ..core.co4e_builtins import BUILTIN_AGENTS
|
|
from ..core.co4e_run_manager import Co4ERunManager
|
|
from ..core.worker import AgentWorker
|
|
from ..i18n import on_language_changed, tr
|
|
from ..theme import current_palette
|
|
from .chat_view import ChatView
|
|
from .co4e_canvas import Co4ECanvas
|
|
from .co4e_config_panel import StepConfigPanel
|
|
from .icons import icon
|
|
from ..presentation.co4e.agent_list_panel import AgentListPanel
|
|
from ..presentation.co4e.co4e_chat_view import (
|
|
ChatPanel, _ChatInput, _agent_names, _directive_token, _skill_names,
|
|
)
|
|
from ..presentation.co4e.co4e_run_control_widget import RunsPagePanel
|
|
from ..presentation.co4e.palette_list import _PaletteList
|
|
from ..presentation.co4e.skills_list_panel import SkillsListPanel
|
|
|
|
|
|
_PLAN_GLYPH = {"completed": "✓", "done": "✓", "in_progress": "▶", "running": "▶",
|
|
"error": "✗", "pending": "○", "todo": "○"}
|
|
|
|
|
|
def _fmt_plan(steps) -> str:
|
|
"""Render plan steps ``[{title,status}]`` as a ticked-off checklist."""
|
|
lines = []
|
|
for s in steps or []:
|
|
title = str((s or {}).get("title", "")).strip()
|
|
if not title:
|
|
continue
|
|
glyph = _PLAN_GLYPH.get(str((s or {}).get("status", "pending")).lower(), "○")
|
|
lines.append(f"{glyph} {title}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
class _EqualTabBar(QTabBar):
|
|
"""Icon-only sidebar tabs (Workflows / Agents / Skills), all the same width,
|
|
sized to fill the sidebar with a comfortable minimum (~double the default
|
|
icon-only width so they read as proper buttons) and an even gap between them.
|
|
The icon sits centered in each tab."""
|
|
|
|
_GAP = 6 # px between tabs — matches the QSS margin-right below
|
|
|
|
def tabSizeHint(self, index): # noqa: N802
|
|
"""Chia đều bề rộng cho mọi tab thay vì để Qt co theo độ dài nhãn.
|
|
|
|
Nhãn dài ngắn khác nhau làm dải tab nhấp nhô mỗi khi đổi tên luồng.
|
|
"""
|
|
base = super().tabSizeHint(index)
|
|
n = self.count() or 1
|
|
avail = self.width()
|
|
if avail <= 1: # width not resolved yet → use parent
|
|
p = self.parentWidget()
|
|
avail = p.width() if p is not None else 0
|
|
share = (avail - n * self._GAP) // n if avail > 1 else 0
|
|
return QSize(max(56, share), max(30, base.height()))
|
|
|
|
def resizeEvent(self, e): # noqa: N802
|
|
"""Đổi bề rộng dải tab thì tính lại bề rộng từng tab."""
|
|
super().resizeEvent(e)
|
|
self.updateGeometry() # re-hint tab widths when resized
|
|
|
|
|
|
from ..presentation.co4e.co4e_flow_tabs import Co4EFlowTabsMixin
|
|
from ..presentation.co4e.co4e_sidebar import Co4ESidebarMixin
|
|
from ..presentation.co4e.co4e_layout import Co4ELayoutMixin
|
|
from ..presentation.co4e.co4e_workflow_crud import Co4EWorkflowCrudMixin
|
|
from ..presentation.co4e.co4e_agents import Co4EAgentsMixin
|
|
from ..presentation.co4e.co4e_runs import Co4ERunsMixin
|
|
from ..presentation.co4e.co4e_chat import Co4EChatMixin
|
|
|
|
|
|
class Co4ETab(
|
|
Co4EFlowTabsMixin,
|
|
Co4ESidebarMixin,
|
|
Co4ELayoutMixin,
|
|
Co4EWorkflowCrudMixin,
|
|
Co4EAgentsMixin,
|
|
Co4ERunsMixin,
|
|
Co4EChatMixin,
|
|
QWidget):
|
|
"""Màn Co4E Studio, ghép từ 7 mixin: dải tab luồng, cột trái, bố cục, CRUD luồng,
|
|
quản lý agent, chạy luồng, và khung chat.
|
|
|
|
Tách thành mixin để mỗi phần nằm trong một file dưới trần 400 dòng; thứ tự
|
|
kế thừa quan trọng — xem ghi chú ở đầu từng file mixin.
|
|
"""
|
|
status_message = Signal(str)
|
|
|
|
def __init__(self, ctx):
|
|
"""Dựng Co4E Studio: khung vẽ workflow, cột nguyên liệu và bảng Flow Status.
|
|
|
|
Mở sẵn một workflow trống để người dùng kéo bước vào ngay, không phải bấm
|
|
"tạo mới" trước.
|
|
"""
|
|
super().__init__()
|
|
self.ctx = ctx
|
|
self._wf: co4e.Workflow = co4e.new_workflow(tr("co4e.untitled"))
|
|
self._chat_worker: Optional[AgentWorker] = None
|
|
# run state
|
|
self.manager = Co4ERunManager(ctx)
|
|
self.manager.changed.connect(self._refresh_runs)
|
|
self.manager.event.connect(self._on_manager_event)
|
|
# Per-flow run state so any number of flow tabs run in PARALLEL without
|
|
# mixing (bounded only by the machine — each run is its own QThread).
|
|
self._flow_runs: Dict[str, str] = {} # wf_id -> its active canvas run id
|
|
self._run_logs: Dict[str, "ChatView"] = {} # run_id -> that flow's chat log
|
|
self._flow_outputs: Dict[str, Dict[str, str]] = {} # wf_id -> {node_id: output}
|
|
# Running token/cost total per flow (↓in ↑out ▤ctx $cost), shown in the
|
|
# Messages header like Cowork's conversation total.
|
|
self._flow_usage: Dict[str, Dict[str, float]] = {}
|
|
self._project_id: str = "" # selected Workspace project
|
|
self._project_dir: Optional[Path] = None # its workspace folder (flow output goes here)
|
|
# manual mode
|
|
self._manual_active = False
|
|
self._manual_order: List[str] = []
|
|
self._manual_idx = 0
|
|
# open flows shown as browser-style tabs (each its own graph; runs are
|
|
# independent via the run manager)
|
|
self._flows: List[co4e.Workflow] = []
|
|
self._active_flow_idx = -1
|
|
|
|
root = QHBoxLayout(self)
|
|
self._split = QSplitter(Qt.Horizontal)
|
|
root.addWidget(self._split)
|
|
|
|
sidebar = self._build_sidebar()
|
|
sidebar.setMinimumWidth(180) # 210 pushed the whole tab past 1214px min
|
|
self._split.addWidget(sidebar)
|
|
self._split.addWidget(self._build_center())
|
|
self.config = StepConfigPanel(ctx)
|
|
self.config.setMinimumWidth(300) # so fields (incl. the model row) are never clipped
|
|
self.config.changed.connect(self._on_config_changed)
|
|
self.config.run_node.connect(lambda nid: self._run_single(nid))
|
|
self.config.run_from.connect(self._run_from)
|
|
self.config.delete_node.connect(self.canvas.delete_node)
|
|
self._config_collapsed = False
|
|
self._config_expanded_w = 360
|
|
self._split.addWidget(self._wrap_config())
|
|
from PySide6.QtCore import QTimer
|
|
|
|
from .widgets import narrow_guard
|
|
self._narrow_guard = narrow_guard(self, self._NARROW, self._apply_narrow_layout)
|
|
# Deferred one tick: the parent chain (and therefore window()) only
|
|
# exists after whoever is building this has finished adding it.
|
|
QTimer.singleShot(0, self._narrow_guard.attach)
|
|
# Start the sidebar wider than its 180px floor — at the floor the
|
|
# "Chạy nền" button and the flow names are cut off.
|
|
self._split.setSizes([230, 720, 360])
|
|
self._split.setStretchFactor(0, 0)
|
|
self._split.setStretchFactor(1, 1)
|
|
self._split.setStretchFactor(2, 0)
|
|
self._split.setStretchFactor(0, 0)
|
|
self._split.setStretchFactor(1, 1)
|
|
self._split.setStretchFactor(2, 0)
|
|
self._split.setSizes([250, 780, 360])
|
|
self._split.setChildrenCollapsible(True)
|
|
|
|
self.canvas.node_selected.connect(self._on_node_selected)
|
|
self.canvas.node_activated.connect(self._on_node_selected)
|
|
self.canvas.graph_changed.connect(self._autosave)
|
|
|
|
self._reload_sidebar()
|
|
self._open_flow(self._wf) # first browser-style flow tab
|
|
self._retranslate() # set Runs table headers etc.
|
|
self._refresh_runs()
|
|
on_language_changed(self._retranslate)
|
|
|
|
# ---- flow tabs (browser-style: several open flows, independent) --------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- per-flow run helpers (parallel, isolated per flow) ---------------
|
|
|
|
|
|
|
|
# ---- sidebar ----------------------------------------------------------
|
|
|
|
_SIDE_RUNS = 6
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- center -----------------------------------------------------------
|
|
|
|
|
|
|
|
# Below this window width the three panes (rail + 180 sidebar + canvas +
|
|
# 300 config) leave the canvas too little to draw a flow in, and the config
|
|
# fields start clipping instead of shrinking. Measured with
|
|
# tools/check_responsive.py — Co4E gets the full content area (no project or
|
|
# history pane beside it), so the threshold is about its own screen only.
|
|
_NARROW = 1300
|
|
|
|
# Chỗ này từng có một ``showEvent`` thứ hai chỉ gọi
|
|
# ``self._narrow_guard.attach()``. Lớp này còn một ``showEvent`` nữa ở phía
|
|
# dưới, và Python giữ định nghĩa SAU CÙNG — nên bản ở đây chưa bao giờ chạy.
|
|
# Đã gộp cả hai việc vào đúng một ``showEvent`` (xem bên dưới).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- per-flow chat logs (each flow = its own conversation) ------------
|
|
|
|
|
|
|
|
|
|
|
|
# ---- workflow load/save ----------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- node selection / config -----------------------------------------
|
|
|
|
|
|
# ---- custom agents ----------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
# ---- running ----------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- manual mode (step-by-step) --------------------------------------
|
|
|
|
|
|
|
|
# ---- run-manager events ----------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- workspace binding + output folder (where flow files land) --------
|
|
def set_project(self, project_id: str) -> None:
|
|
"""Bind Co4E to the SELECTED Workspace project so flow output (and per-run
|
|
folders) land in THAT project's workspace — mirroring how Cowork writes to
|
|
the project folder — instead of the global/config output dir."""
|
|
from ..core.projects import load_project
|
|
self._project_id = project_id or ""
|
|
self._project_dir = None
|
|
if project_id and project_id not in ("", "default"):
|
|
proj = load_project(project_id)
|
|
if proj is not None:
|
|
self._project_dir = Path(proj.workspace_dir())
|
|
# Route the run manager's output at the selected workspace, and filter
|
|
# Flow Status to this workspace's runs.
|
|
self.manager.set_output_root(self._flow_output_root())
|
|
self.manager.set_current_project(self._project_id)
|
|
if hasattr(self, "ws_folder_btn"):
|
|
self._refresh_ws_folder_btn()
|
|
|
|
def _flow_output_root(self) -> Path:
|
|
"""The workspace folder flow outputs are written under (one subfolder per
|
|
flow). Uses the SELECTED project's workspace when one is bound, else the
|
|
global Cowork output dir. Mirrors co4e_run_manager._out_dir's base."""
|
|
if self._project_dir is not None:
|
|
return self._project_dir / "co4e"
|
|
try:
|
|
base = self.ctx.config.cowork_output_dir()
|
|
except Exception: # noqa: BLE001
|
|
base = co4e.CO4E_DIR / "runs"
|
|
return Path(base) / "co4e"
|
|
|
|
def _refresh_ws_folder_btn(self) -> None:
|
|
"""Cập nhật nhãn nút thư mục làm việc, rút gọn còn hai cấp cuối cho đỡ dài."""
|
|
root = self._flow_output_root()
|
|
parts = root.parts
|
|
short = "…/" + "/".join(parts[-2:]) if len(parts) > 2 else str(root)
|
|
self.ws_folder_btn.setText(short)
|
|
self.ws_folder_btn.setToolTip(tr("co4e.tt_open_workspace", path=str(root)))
|
|
|
|
def _open_workspace_folder(self) -> None:
|
|
"""Mở thư mục kết quả của luồng trong trình quản lý tệp của hệ điều hành."""
|
|
from .osutil import open_location
|
|
root = self._flow_output_root()
|
|
try:
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
except OSError:
|
|
pass
|
|
open_location(str(root))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def showEvent(self, e): # noqa: N802
|
|
"""Mỗi lần tab được hiện lại: làm mới bảng Flow Status và gắn lại bộ canh
|
|
bố cục hẹp.
|
|
|
|
Gắn lại bộ canh là cần: widget có thể đã được chuyển sang cửa sổ khác kể
|
|
từ lần hiện trước, và bộ canh theo dõi CỬA SỔ chứ không theo dõi widget.
|
|
"""
|
|
# Guarantee the status list is current whenever the tab is shown again.
|
|
self._refresh_runs()
|
|
self._narrow_guard.attach()
|
|
super().showEvent(e)
|
|
|
|
def _out_dir(self) -> Path:
|
|
# Save chat/flow deliverables into the SELECTED workspace (the active
|
|
# project's folder via _flow_output_root), so files land where the user
|
|
# works with them — not in the config/install folder.
|
|
"""Thư mục ghi kết quả của luồng đang mở, tạo sẵn nếu chưa có.
|
|
|
|
Ghi vào thư mục của workspace đang chọn để file rơi đúng chỗ người dùng làm
|
|
việc, không rơi vào thư mục cài đặt.
|
|
"""
|
|
d = self._flow_output_root() / co4e.slugify(self._wf.name or "flow")
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
return d
|
|
|
|
# ---- chat (with /agent /skill directives) -----------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- token / cost accounting (shown per-message + as a flow total) ------
|
|
|
|
|
|
|
|
|
|
|
|
# ---- i18n -------------------------------------------------------------
|
|
def _retranslate(self) -> None:
|
|
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề các mục và tooltip.
|
|
|
|
Cột trái và trang Flow Status không có mặt ở đây: mỗi widget bên đó tự
|
|
ràng buộc khoá dịch của mình tại chỗ dựng (``i18n.bind_*``), nên panel
|
|
dùng ở đâu cũng đúng ngôn ngữ mà không cần ai nhớ hộ.
|
|
"""
|
|
self.runs_btn.setText(tr("co4e.runs_tab"))
|
|
self.runs_btn.setToolTip(tr("co4e.tt_runs_tab"))
|
|
# Flow toolbar.
|
|
self.name_edit.setToolTip(tr("co4e.tt_flow_name"))
|
|
self.add_step_btn.setText(tr("co4e.add"))
|
|
self.add_step_btn.setToolTip(tr("co4e.tt_add_step"))
|
|
self.save_btn.setText(tr("co4e.save"))
|
|
self.save_btn.setToolTip(tr("co4e.tt_save"))
|
|
self.save_tpl_btn.setToolTip(tr("co4e.tt_save_template"))
|
|
self.mode_combo.setToolTip(tr("co4e.tt_mode"))
|
|
# By position, from the same source the items were built from: the mode
|
|
# string in each item's data is persisted, so it must survive a
|
|
# translation untouched.
|
|
for i, mode in enumerate(co4e.RUN_MODES):
|
|
self.mode_combo.setItemText(i, tr(f"co4e.mode.{mode}"))
|
|
self.run_btn.setToolTip(tr("co4e.tt_run"))
|
|
# Not a plain tr(): this button reads "Dừng" while THIS flow is running.
|
|
self._update_run_btn()
|
|
# Step-config panel header. The toggle's tooltip names the action it
|
|
# would perform, which depends on which way the panel is folded.
|
|
self.config_title.setText(tr("co4e.config_title"))
|
|
self.config_toggle_btn.setToolTip(tr(
|
|
"co4e.tt_expand_config" if self._config_collapsed else "co4e.tt_collapse_config"))
|
|
self._refresh_ws_folder_btn()
|
|
self.runs_table.setHorizontalHeaderLabels([
|
|
tr("co4e.runs_col_flow"), tr("co4e.runs_col_status"), tr("co4e.runs_col_steps"),
|
|
tr("co4e.runs_col_by"), tr("co4e.runs_col_at")])
|
|
self._reload_sidebar()
|
|
self._refresh_runs()
|
|
|
|
|
|
def _html_escape(text: str) -> str:
|
|
"""Thoát ba ký tự HTML nguy hiểm, để nội dung người dùng không phá vỡ khung chat."""
|
|
return (text or "").replace("&", "&").replace("<", "<").replace(">", ">")
|
|
|
|
|
|
def _qcolor(hex_str: str):
|
|
"""Đổi chuỗi màu hex thành ``QColor``. Import muộn để module này nạp được ở nơi
|
|
chưa có Qt GUI.
|
|
"""
|
|
from PySide6.QtGui import QColor
|
|
return QColor(hex_str)
|