chore: sync local working copy as of 2026-08-15

The Gitea repo was initialised from an earlier snapshot, so main and the
machine this runs on had drifted apart in 153 files before any UI work
started. This commit brings the branch up to the local tree as it stood
on 2026-08-15 21:31 (from cowork_local.7z), so the redesign that follows
shows up as its own reviewable diff instead of being mixed in with the
pre-existing divergence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
NamPDT
2026-08-17 11:38:18 +09:00
co-authored by Claude Opus 5
parent 414eaddca3
commit 291a611737
96 changed files with 12491 additions and 2937 deletions
File diff suppressed because it is too large Load Diff
+378
View File
@@ -0,0 +1,378 @@
"""Capture every CoworkLocal screen to PNG, offscreen, for the UI audit page.
Run: python tools/capture_screens.py
Two safety measures, both mandatory — this script drives the REAL application:
1. **Data isolation.** ``config.CONFIG_DIR`` is ``Path.home() / ".cowork_local"``, a
module-level constant resolved at import time. We copy that folder to a temp
directory and repoint ``USERPROFILE``/``HOME`` at it *before* importing
``cowork_local``, so every write the app makes lands in the copy. The user's
real data is never opened for writing.
2. **Schedulers disabled.** ``MainWindow.__init__`` starts ``TaskScheduler`` and
``RoutingScheduler``, which would *execute the user's scheduled tasks* — real
agent turns writing real files. Both ``start`` methods are patched to no-ops
before the window is built.
We also construct ``MainWindow`` directly rather than calling ``app.run()``:
``run()`` seeds built-in skills/flows and calls ``ctx.config.save()``.
Screens that fail to render (QtWebEngine generally cannot initialise offscreen)
are recorded in the manifest with their error. They are never silently skipped —
the audit page renders an explicit "could not capture" placeholder for them.
"""
from __future__ import annotations
import json
import os
import shutil
import sys
import tempfile
import traceback
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent # …/cowork_local
OUT_DIR = REPO / "docs" / "screens"
THEMES = ("dark", "light")
def _isolate_home() -> Path:
"""Copy the real config dir into a temp HOME and repoint the env at it."""
real = Path.home() / ".cowork_local"
sandbox = Path(tempfile.mkdtemp(prefix="cowork-capture-"))
if real.exists():
shutil.copytree(real, sandbox / ".cowork_local", dirs_exist_ok=True)
else:
(sandbox / ".cowork_local").mkdir(parents=True, exist_ok=True)
for var in ("USERPROFILE", "HOME"):
os.environ[var] = str(sandbox)
os.environ.pop("HOMEDRIVE", None)
os.environ.pop("HOMEPATH", None)
return sandbox
def _load_fonts() -> int:
"""Register system fonts with the offscreen platform.
The offscreen plugin ships with NO font database (``QFontDatabase.families()``
returns an empty list), so every glyph renders as a tofu box — unusable when
the screenshots are the deliverable. Loading the real Windows faces fixes
both Latin and Vietnamese diacritics, and Consolas covers the code views.
"""
from PySide6.QtGui import QFontDatabase
wanted = [
"SegUIVar.ttf", "segoeui.ttf", "segoeuib.ttf", "segoeuii.ttf",
"seguisb.ttf", "consola.ttf", "consolab.ttf", "arial.ttf",
]
root = Path(os.environ.get("SystemRoot", r"C:\Windows")) / "Fonts"
loaded = 0
for name in wanted:
path = root / name
if path.exists() and QFontDatabase.addApplicationFont(str(path)) != -1:
loaded += 1
return loaded
def _freeze_schedulers() -> None:
"""No-op the background engines so nothing is executed while we capture."""
from cowork_local.core.task_scheduler import TaskScheduler
TaskScheduler.start = lambda self: None # type: ignore[assignment]
try:
from cowork_local.core.routing.scheduler import RoutingScheduler
RoutingScheduler.start = lambda self: None # type: ignore[assignment]
except Exception:
pass
def main() -> int:
os.environ["QT_QPA_PLATFORM"] = "offscreen"
sandbox = _isolate_home()
sys.path.insert(0, str(REPO.parent)) # so `import cowork_local` works
sys.path.insert(0, str(Path(__file__).resolve().parent)) # sibling: seed_demo_data
OUT_DIR.mkdir(parents=True, exist_ok=True)
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication
app = QApplication([])
n_fonts = _load_fonts()
print(f"[fonts] registered {n_fonts} face(s) with the offscreen platform")
if not n_fonts:
print(" WARNING: no fonts loaded — every screenshot will render as tofu boxes")
_freeze_schedulers()
import cowork_local.theme as theme
from cowork_local.config import AppConfig, CONFIG_DIR
from cowork_local.i18n import set_language, tr
from cowork_local.state import AppContext
assert str(sandbox) in str(CONFIG_DIR), (
f"isolation failed: CONFIG_DIR={CONFIG_DIR} is not inside {sandbox}")
print(f"[isolated] CONFIG_DIR -> {CONFIG_DIR}")
# Fill the sandbox with demo data so the screenshots show a working app.
# Safe by construction: seed() re-asserts it is inside a capture sandbox.
from seed_demo_data import seed
counts = seed()
print("[seeded] " + " · ".join(f"{k}={v}" for k, v in counts.items()))
from cowork_local.app import MainWindow
cfg = AppConfig.load()
set_language("vi")
ctx = AppContext(cfg)
manifest: list[dict] = []
# Label of the nav row selected right now, recorded into every shot so the
# "is the rail pointing at the right thing?" question is machine-checked
# instead of eyeballed across 54 images.
nav_state = {"label": "", "expected": ""}
def nav_to(win, page: int, sub=None, expect: str = "") -> None:
"""Navigate the way a user does: SELECT the nav-rail row.
Calling ``win._goto()`` directly swaps the content but leaves the rail
highlighting whatever was selected before — so a Co4E screenshot showed
the content of Co4E with "Workspace" still lit. Setting the current item
fires currentItemChanged → _navigate → _goto, i.e. both halves.
"""
win._ensure_page(page) # build lazy page + its children
item = win._nav_items[page]
if sub is None:
win.nav.setCurrentItem(item)
app.processEvents()
else:
# Two steps, because selecting the row alone does not survive.
#
# Navigating to Workspace runs refresh(), which emits
# subtabs_changed → _reload_nav_children → takeChildren(); that
# DESTROYS the row just selected and the highlight falls back to the
# parent. Retrying only re-triggers the same cascade. (Real app bug,
# reproduced in test_nav_bug.py: 5/5 Workspace rows lose the
# highlight, 0/8 Monitoring rows do.)
#
# So: drive the content first, let the rebuild settle, then set the
# highlight with signals blocked so it cannot cascade again. The
# screenshot then shows what the user *should* see; the underlying
# bug is reported separately in the audit page.
win._goto(page, sub)
app.processEvents()
app.processEvents()
if item.childCount() == 0:
win._reload_nav_children(page)
item.setExpanded(True)
target = next(
(item.child(i) for i in range(item.childCount())
if (item.child(i).data(0, Qt.UserRole) or {}).get("sub") == sub),
None)
assert target is not None, f"nav child page={page} sub={sub} not found"
blocked = win.nav.blockSignals(True)
win.nav.setCurrentItem(target)
win.nav.blockSignals(blocked)
app.processEvents()
cur = win.nav.currentItem()
nav_state["label"] = cur.text(0) if cur is not None else ""
nav_state["expected"] = expect or nav_state["label"]
def shot(widget, slug: str, title: str, note: str = "") -> None:
"""Grab `widget` for the active theme; record success or the error."""
rec = {"slug": slug, "title": title, "theme": theme.current_theme(),
"note": note, "file": "", "error": "",
"nav": nav_state["label"], "nav_expected": nav_state["expected"]}
try:
app.processEvents()
app.processEvents()
pm = widget.grab()
if pm.isNull() or pm.width() < 2:
raise RuntimeError("grab() returned an empty pixmap")
name = f"{slug}-{theme.current_theme()}.png"
pm.save(str(OUT_DIR / name))
rec["file"] = f"screens/{name}"
print(f" ok {name} ({pm.width()}x{pm.height()})")
except Exception as exc: # noqa: BLE001
rec["error"] = f"{type(exc).__name__}: {exc}"
print(f" FAIL {slug}: {rec['error']}")
manifest.append(rec)
for th in THEMES:
print(f"\n=== theme: {th} ===")
ctx.config.theme = th
theme.set_active_theme(th)
app.setStyleSheet(theme.stylesheet(th))
win = MainWindow(ctx, user_name="local")
win.resize(1600, 1000)
win.show()
app.processEvents()
# ---- main screens, driven through the app's own navigation API ------
ROW_DASH, ROW_SCHED, ROW_WS, ROW_MON = 0, 1, 2, 3
nav_to(win, ROW_DASH, None, expect=tr("app.tab.dashboard"))
shot(win, "dashboard", "Dashboard", "ui/dashboard_tab.py:35")
nav_to(win, ROW_SCHED, None, expect=tr("app.tab.schedule"))
sched = win._page_widgets[ROW_SCHED]
shot(win, "schedule-kanban", "Schedule Task — Kanban", "ui/schedule_task_tab.py:70")
try: # combo index 1 == Calendar view
sched.view_combo.setCurrentIndex(1)
app.processEvents()
shot(win, "schedule-calendar", "Schedule Task — Calendar", "ui/calendar_view.py:88")
sched.view_combo.setCurrentIndex(0)
except Exception as exc: # noqa: BLE001
manifest.append({"slug": "schedule-calendar", "title": "Schedule Task — Calendar",
"theme": th, "note": "ui/calendar_view.py:88", "file": "",
"error": f"{type(exc).__name__}: {exc}"})
print(f" FAIL schedule-calendar: {exc}")
# Workspace: capture with no project selected, then with one selected so
# the project-gated sub-tabs (Cowork, GraphRAG) actually exist.
nav_to(win, ROW_WS, None, expect=tr("app.tab.workspace"))
ws = win.workspace
shot(win, "workspace-project", "Workspace ▸ Project", "ui/workspace_tab.py:188")
try:
if ws.project_list.count():
ws.project_list.setCurrentRow(0)
app.processEvents()
except Exception: # noqa: BLE001
pass
for attr, slug, title, note in (
("_cowork_tab_idx", "workspace-cowork", "Workspace ▸ Cowork", "ui/cowork_tab.py:21"),
("_co4e_tab_idx", "workspace-co4e", "Workspace ▸ Co4E", "ui/co4e_tab.py:228"),
("_folder_tab_idx", "workspace-folder", "Workspace ▸ Folder", "ui/folder_tab.py:238"),
("_graphrag_tab_idx", "workspace-graphrag", "Workspace ▸ GraphRAG", "ui/structure_graph_view.py:188"),
):
idx = getattr(ws, attr, None)
if idx is None:
manifest.append({"slug": slug, "title": title, "theme": th, "note": note,
"file": "", "error": "sub-tab index not present"})
continue
nav_to(win, ROW_WS, idx, expect=ws.tabs.tabText(idx))
shot(win, slug, title, note)
# Monitoring: enumerate its sub-tabs from the app itself.
nav_to(win, ROW_MON, None, expect=tr("app.tab.monitoring"))
mon = win._page_widgets[ROW_MON]
try:
subs = mon.nav_subtabs()
except Exception as exc: # noqa: BLE001
subs = []
print(f" FAIL monitoring subtabs: {exc}")
for label, sub, _icon in subs:
nav_to(win, ROW_MON, sub, expect=label)
slug = "monitoring-" + "".join(
c.lower() if c.isalnum() else "-" for c in label).strip("-")
shot(win, slug, f"Monitoring ▸ {label}", "ui/monitoring_tab.py:132")
# Dialogs/overlays below are not nav destinations.
nav_state["label"] = nav_state["expected"] = ""
# ---- dialogs: built directly and shown (never exec(), it blocks) -----
for slug, title, note, build in _dialog_specs(ctx, win):
try:
dlg = build()
dlg.show()
app.processEvents()
shot(dlg, slug, title, note)
dlg.close()
except Exception as exc: # noqa: BLE001
manifest.append({"slug": slug, "title": title, "theme": th, "note": note,
"file": "", "error": f"{type(exc).__name__}: {exc}"})
print(f" FAIL {slug}: {type(exc).__name__}: {exc}")
# ---- overlays --------------------------------------------------------
try:
help_dock = win.help_agent
help_dock._expand()
app.processEvents()
shot(help_dock, "overlay-help-panel", "Help dock — expanded panel",
"ui/help_agent_widget.py:79")
except Exception as exc: # noqa: BLE001
print(f" FAIL overlay-help-panel: {exc}")
win.close()
(OUT_DIR / "manifest.json").write_text(
json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
bad_nav = [r for r in manifest
if r["nav_expected"] and r["nav"] != r["nav_expected"]]
checked = sum(1 for r in manifest if r["nav_expected"])
print(f"\n[nav] rail selection matches the screen: {checked - len(bad_nav)}/{checked}")
for r in bad_nav:
print(f" MISMATCH {r['slug']} [{r['theme']}]: "
f"rail says '{r['nav']}', screen is '{r['nav_expected']}'")
ok = sum(1 for r in manifest if r["file"])
bad = [r for r in manifest if not r["file"]]
print(f"\ncaptured {ok}/{len(manifest)}")
if bad:
print("could NOT capture (recorded in manifest, shown as placeholders):")
for r in bad:
print(f" - {r['slug']} [{r['theme']}]: {r['error']}")
print(f"sandbox (safe to delete): {sandbox}")
return 0
def _dialog_specs(ctx, win):
"""(slug, title, note, factory) for each dialog we can build headlessly.
Signatures differ per dialog (some take ctx first, some take parent first,
some require a real model object) — each factory below matches the actual
``__init__`` it calls, not a guessed one.
"""
# NOTE: two different classes share the name `CustomAgent` —
# core/custom_agents.py:23 and core/co4e.py:117. Co4EAgentDialog uses the
# co4e one (it has `.role`); importing the other raises AttributeError.
from cowork_local.core.co4e import CustomAgent
from cowork_local.ui.settings_dialog import SettingsDialog
from cowork_local.ui.task_editor_dialog import TaskEditorDialog
from cowork_local.ui.skills_dialog import SkillsDialog, SkillEditDialog
from cowork_local.ui.file_edit_dialog import FileEditDialog
from cowork_local.ui.co4e_agent_dialog import Co4EAgentDialog
from cowork_local.ui.ext_connector_dialog import ExtConnectorEditDialog
from cowork_local.ui.permission_dialog import PermissionDialog
from cowork_local.ui.agents_admin_tab import AgentEditDialog
from cowork_local.ui.login_dialog import LoginDialog
return [
# SettingsDialog(ctx, parent)
("dialog-settings", "Settings", "ui/settings_dialog.py:26",
lambda: SettingsDialog(ctx, win)),
# TaskEditorDialog(task, all_tasks, parent, ctx)
("dialog-task-editor", "Task Editor", "ui/task_editor_dialog.py:55",
lambda: TaskEditorDialog(None, [], win, ctx)),
# SkillsDialog(parent, ctx)
("dialog-skills", "Skills manager", "ui/skills_dialog.py:108",
lambda: SkillsDialog(win, ctx)),
# SkillEditDialog(parent, skill, ctx)
("dialog-skill-edit", "Skill editor", "ui/skills_dialog.py:23",
lambda: SkillEditDialog(win, None, ctx)),
("dialog-file-edit", "File view & AI edit", "ui/file_edit_dialog.py:50",
lambda: FileEditDialog(ctx, "", win)),
# Co4EAgentDialog(ctx, agent, skill_names, parent) — agent must be real
("dialog-co4e-agent", "Co4E agent editor", "ui/co4e_agent_dialog.py:23",
lambda: Co4EAgentDialog(ctx, CustomAgent(id="preview"), [], win)),
# ExtConnectorEditDialog(parent, category, connector)
("dialog-ext-connector", "External connector", "ui/ext_connector_dialog.py:23",
lambda: ExtConnectorEditDialog(win, "other", None)),
# PermissionDialog(action, parent) — `preview` is a dict, not a string
("dialog-permission", "Permission request", "ui/permission_dialog.py:13",
lambda: PermissionDialog(
{"name": "run_command",
"preview": {"title": "Run command", "kind": "command",
"text": "npm install --save-dev vitest"}}, win)),
# AgentEditDialog(parent, ctx, agent, default_model_hint)
("dialog-agent-edit", "Admin agent editor", "ui/agents_admin_tab.py:35",
lambda: AgentEditDialog(win, ctx, None, "")),
("dialog-login", "Login (dead screen — not wired)", "ui/login_dialog.py:57",
lambda: LoginDialog(ctx, win)),
]
if __name__ == "__main__":
raise SystemExit(main())
+137
View File
@@ -0,0 +1,137 @@
"""Extract every interactive control from the UI source, mechanically.
Reading the files by hand and listing what I notice is exactly how functionality
gets dropped from a redesign. This walks the AST instead, so the inventory is
exhaustive by construction: if a widget is constructed in the file, it appears.
For each control it reports the variable it is bound to, its widget type, the
label expression (usually a ``tr("...")`` key), the signal handlers wired to it,
and the source line — enough to check "did the new design keep this?".
Run: python tools/extract_controls.py [ui/file.py ...]
"""
from __future__ import annotations
import ast
import io
import json
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
UI = REPO / "ui"
# Widget types that represent something the user can click, type in or toggle.
WIDGETS = {
"QPushButton": "nút", "QToolButton": "nút icon", "QComboBox": "droplist",
"QCheckBox": "ô tick", "QRadioButton": "radio", "QLineEdit": "ô nhập",
"QPlainTextEdit": "ô nhập nhiều dòng", "QTextEdit": "ô nhập nhiều dòng",
"QSpinBox": "ô số", "QDoubleSpinBox": "ô số", "QDateTimeEdit": "ô ngày giờ",
"QDateEdit": "ô ngày", "QTimeEdit": "ô giờ", "QSlider": "thanh trượt",
"QListWidget": "danh sách", "QTreeWidget": "cây", "QTableWidget": "bảng",
"QTabWidget": "dải tab", "QTabBar": "dải tab", "QDialogButtonBox": "nút hộp thoại",
}
# Signals worth recording — these are the "it does something" wires.
SIGNALS = {
"clicked", "toggled", "currentIndexChanged", "currentTextChanged",
"textChanged", "returnPressed", "valueChanged", "itemClicked",
"itemDoubleClicked", "currentItemChanged", "currentChanged",
"customContextMenuRequested", "tabCloseRequested", "linkActivated",
"stateChanged", "activated", "triggered", "editingFinished",
}
def _txt(node) -> str:
"""Best-effort source text for a label expression."""
try:
return ast.unparse(node)
except Exception: # noqa: BLE001
return "?"
class Visitor(ast.NodeVisitor):
def __init__(self, path: Path):
self.path = path
self.controls: dict[str, dict] = {} # var name -> record
self.menu_actions: list[dict] = []
# ---- self.btn = QPushButton(...) / btn = QComboBox() -------------------
def visit_Assign(self, node: ast.Assign) -> None:
if isinstance(node.value, ast.Call):
fn = node.value.func
name = fn.id if isinstance(fn, ast.Name) else getattr(fn, "attr", "")
if name in WIDGETS:
for tgt in node.targets:
var = _txt(tgt)
args = [_txt(a) for a in node.value.args]
self.controls.setdefault(var, {
"var": var, "type": name, "kind": WIDGETS[name],
"label": args[0] if args else "",
"line": node.lineno, "signals": [], "object_name": "",
})
self.generic_visit(node)
def visit_Call(self, node: ast.Call) -> None:
fn = node.func
# ---- x.clicked.connect(handler) ----------------------------------
if isinstance(fn, ast.Attribute) and fn.attr == "connect":
sig = fn.value
if isinstance(sig, ast.Attribute) and sig.attr in SIGNALS:
var = _txt(sig.value)
rec = self.controls.get(var)
if rec is not None and node.args:
rec["signals"].append(f"{sig.attr} → {_txt(node.args[0])}")
# ---- x.setText(tr("...")) / setObjectName / setToolTip -----------
if isinstance(fn, ast.Attribute) and node.args:
var = _txt(fn.value)
rec = self.controls.get(var)
if rec is not None:
if fn.attr in ("setText", "setPlaceholderText") and not rec["label"]:
rec["label"] = _txt(node.args[0])
elif fn.attr == "setObjectName":
rec["object_name"] = _txt(node.args[0]).strip("'\"")
elif fn.attr == "setToolTip" and not rec["label"]:
rec["label"] = _txt(node.args[0])
# ---- menu.addAction("Xoá") — context menus are real features -----
if isinstance(fn, ast.Attribute) and fn.attr == "addAction" and node.args:
self.menu_actions.append({
"menu": _txt(fn.value), "label": _txt(node.args[0]),
"line": node.lineno,
})
self.generic_visit(node)
def scan(path: Path) -> dict:
tree = ast.parse(io.open(path, encoding="utf-8").read(), filename=str(path))
v = Visitor(path)
v.visit(tree)
# Drop pure containers with no wiring and no label — they are layout, not
# controls the user acts on directly.
controls = [c for c in v.controls.values()
if c["signals"] or c["label"] or c["object_name"]]
controls.sort(key=lambda c: c["line"])
return {"file": str(path.relative_to(REPO)),
"controls": controls, "menu_actions": v.menu_actions}
def main(argv: list[str]) -> int:
targets = [Path(a) for a in argv] or sorted(UI.glob("*.py"))
out = []
for t in targets:
if t.name == "__init__.py":
continue
p = t if t.is_absolute() else (REPO / t if (REPO / t).exists() else t)
try:
out.append(scan(p))
except SyntaxError as exc: # noqa: PERF203
print(f" SKIP {p.name}: {exc}", file=sys.stderr)
dest = REPO / "docs" / "screens" / "controls.json"
dest.write_text(json.dumps(out, indent=1, ensure_ascii=False), encoding="utf-8")
n_ctl = sum(len(f["controls"]) for f in out)
n_act = sum(len(f["menu_actions"]) for f in out)
print(f"{len(out)} file · {n_ctl} control · {n_act} mục menu chuột phải → {dest}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+352
View File
@@ -0,0 +1,352 @@
"""Populate a CoworkLocal config dir with realistic demo data, so the audit
screenshots show a working app instead of empty lists.
MUST be imported only AFTER ``USERPROFILE``/``HOME`` have been repointed at a
sandbox — every store below resolves its path from ``CONFIG_DIR``, which is
``Path.home()/".cowork_local"`` evaluated at import time. ``seed()`` asserts this.
Where the app exposes a write API we call it (projects, history, tasks, skills,
workflows, agents). Two stores are written as raw files on purpose:
* **usage** and **audit** — their ``record()`` helpers always stamp
``datetime.now()``, so they cannot backdate. A one-day spike makes a useless
chart, so the day files are written directly.
* **co4e/run_history.json** — the manager only persists from a Qt signal
handler; there is no public save.
"""
from __future__ import annotations
import json
import os
import random
from datetime import datetime, timedelta
from pathlib import Path
rnd = random.Random(20260808) # fixed seed → identical screenshots every run
PROJECTS = [
("Trạm sạc EV — Cổng vận hành",
"Cổng nội bộ theo dõi trạm sạc: bản đồ trạng thái, cảnh báo, báo cáo doanh thu.",
"Trả lời bằng tiếng Việt. Backend FastAPI + PostgreSQL, frontend React.\n"
"Luôn viết test trước khi sửa logic thanh toán."),
("Báo cáo tài chính Q3",
"Tự động gom số liệu từ Excel phòng ban, dựng bảng tổng hợp và slide trình bày.",
"Đơn vị tiền tệ mặc định VND. Làm tròn tới nghìn đồng.\n"
"Mọi con số phải truy được về file nguồn."),
("Cổng tra cứu tài liệu ISO",
"Tìm kiếm ngữ nghĩa trên kho tài liệu ISO 9001 của công ty.",
"Ưu tiên trích dẫn đúng số hiệu điều khoản.\nKhông suy diễn ngoài tài liệu."),
]
CONVERSATIONS = [
(0, "Dựng API trạng thái trạm sạc", True, [
("user", "Giúp mình dựng endpoint GET /stations/{id}/status trả về trạng thái realtime."),
("assistant", "Mình đã đọc `app/models/station.py` và thấy sẵn bảng `station_heartbeat`.\n\n"
"Đề xuất: endpoint đọc heartbeat mới nhất trong 90 giây, quá hạn thì trả `offline`.\n"
"Mình tạo `app/api/stations.py` và test đi kèm nhé?"),
("user", "Ừ làm đi."),
("assistant", "Đã tạo 2 file:\n- `app/api/stations.py` — router + schema phản hồi\n"
"- `tests/test_stations.py` — 4 ca: online, offline, không tồn tại, heartbeat lỗi\n\n"
"Chạy `pytest tests/test_stations.py` → 4 passed."),
]),
(0, "Sửa lỗi tính tiền sai khi phiên sạc bị ngắt", False, [
("user", "Khách báo bị tính dư tiền khi rút sạc đột ngột giữa chừng."),
("assistant", "Mình tìm thấy nguyên nhân ở `billing/session.py:142`: khi phiên kết thúc bất thường, "
"`ended_at` vẫn để `None` nên hàm tính giờ lấy `now()` — tiền chạy tiếp tới lúc job dọn chạy.\n\n"
"Sửa: lấy mốc heartbeat cuối cùng làm `ended_at`. Mình chuẩn bị diff nhé?"),
]),
(1, "Gom số liệu doanh thu từ 6 file phòng ban", True, [
("user", "Có 6 file Excel trong thư mục input, gom lại thành 1 bảng tổng hợp giúp mình."),
("assistant", "Đã đọc cả 6 file. Lưu ý: `PB_Marketing.xlsx` để cột “Doanh thu” ở vị trí khác "
"(cột F thay vì D) và có 3 dòng trống ở cuối.\n\n"
"Mình đã chuẩn hoá và xuất `tonghop_q3.xlsx` — 1.284 dòng, tổng 42.7 tỷ VND."),
]),
(1, "Dựng slide trình bày kết quả Q3", False, [
("user", "Từ file tổng hợp, làm giúp bộ slide 8 trang."),
("assistant", "Đã tạo `BaoCao_Q3.pptx`: tổng quan → doanh thu theo phòng ban → so sánh Q2 → "
"top 5 tăng trưởng → rủi ro → kế hoạch Q4."),
]),
(2, "Đánh chỉ mục kho tài liệu ISO", False, [
("user", "Quét thư mục docs/iso và dựng đồ thị tri thức."),
("assistant", "Đã quét 148 tài liệu, dựng 1.902 node và 3.418 cạnh. "
"Có 12 tài liệu bị trùng số hiệu điều khoản, mình liệt kê ở `trung_lap.md`."),
]),
(0, "Rà soát bảo mật trước khi lên production", False, [
("user", "Kiểm tra giúp mình các chỗ có rủi ro bảo mật."),
("assistant", "Tìm thấy 3 điểm cần xử lý:\n1. API key Stripe hard-code ở `config/payment.py:31`\n"
"2. Endpoint `/admin/export` không kiểm tra quyền\n"
"3. CORS đang để `allow_origins=[\"*\"]`"),
]),
]
TASKS = [
# (title, status, priority, ai, enabled, offset_days, last_status)
("Đồng bộ heartbeat trạm sạc mỗi 5 phút", "running", "high", False, True, 0, "success"),
("Gửi báo cáo doanh thu hằng ngày 08:00", "scheduled", "medium", False, True, 1, "success"),
("Quét lại chỉ mục ISO cuối tuần", "scheduled", "low", False, True, 3, "success"),
("Dựng slide tổng kết Q3", "done", "high", True, False, -2, "success"),
("Kiểm tra chứng chỉ TLS sắp hết hạn", "failed", "critical", False, True, -1, "failed"),
("Chờ kế toán duyệt số liệu tháng 7", "waiting_input", "medium", False, False, -3, None),
("Dọn log cũ hơn 90 ngày", "paused", "low", False, False, 7, "success"),
("Xuất danh sách khách hàng B2B", "backlog", "low", True, False, 5, None),
("Rà soát bảo mật trước release", "backlog", "high", False, False, 2, None),
("Sao lưu cơ sở dữ liệu hằng đêm", "done", "critical", False, True, -1, "success"),
]
SKILLS = [
("Rà soát bảo mật", "Quét mã tìm lộ khoá, thiếu kiểm tra quyền, cấu hình CORS lỏng.",
"Khi được gọi, hãy rà soát theo thứ tự:\n1. Bí mật hard-code (API key, mật khẩu, token)\n"
"2. Endpoint thiếu kiểm tra xác thực/phân quyền\n3. Cấu hình CORS, CSP, cookie\n"
"4. Truy vấn SQL ghép chuỗi\nMỗi phát hiện phải kèm file:dòng và cách sửa cụ thể."),
("Chuẩn hoá bảng Excel", "Gom nhiều file Excel lệch cấu trúc về một bảng thống nhất.",
"Đọc từng file, dò vị trí cột theo tiêu đề chứ không theo chỉ số cột.\n"
"Bỏ dòng trống ở cuối. Báo rõ file nào lệch cấu trúc và lệch ra sao."),
("Viết test trước", "Sinh test cho hành vi mong muốn trước khi sửa mã.",
"Trước khi sửa logic, viết test mô tả hành vi đúng.\n"
"Chạy test để xác nhận nó FAIL, rồi mới sửa mã cho nó PASS."),
("Tóm tắt tài liệu ISO", "Tóm tắt điều khoản ISO kèm trích dẫn số hiệu.",
"Luôn trích dẫn số hiệu điều khoản. Không suy diễn ngoài văn bản.\n"
"Nếu tài liệu mâu thuẫn nhau, nêu rõ cả hai và chỉ ra chỗ mâu thuẫn."),
("Dựng slide từ số liệu", "Chuyển bảng số liệu thành bộ slide trình bày.",
"Mỗi slide một thông điệp. Biểu đồ phải có nhãn trục và đơn vị.\n"
"Slide cuối luôn là hành động tiếp theo."),
]
CO4E_AGENTS = [
("Phân tích yêu cầu", "ANALYST", "search",
"Đọc mô tả yêu cầu, bóc tách thành danh sách hạng mục rõ ràng, đánh dấu chỗ còn mơ hồ.",
["Rà soát bảo mật"]),
("Thiết kế giải pháp", "ARCHITECT", "flow",
"Từ danh sách hạng mục, đề xuất kiến trúc và các bước triển khai, nêu rõ đánh đổi.", []),
("Lập trình viên", "CODER", "code",
"Hiện thực theo thiết kế. Viết test trước khi sửa logic nghiệp vụ.", ["Viết test trước"]),
("Kiểm thử", "TESTER", "shield",
"Chạy test, đọc log lỗi, báo cáo ca nào hỏng và vì sao.", ["Rà soát bảo mật"]),
("Soạn tài liệu", "WRITER", "book",
"Viết tài liệu hướng dẫn sử dụng từ mã nguồn và test.", ["Tóm tắt tài liệu ISO"]),
]
WORKFLOWS = [
("Quy trình phát triển tính năng",
["Phân tích yêu cầu", "Thiết kế giải pháp", "Lập trình viên", "Kiểm thử", "Soạn tài liệu"]),
("Rà soát bảo mật định kỳ", ["Phân tích yêu cầu", "Kiểm thử"]),
("Dựng báo cáo từ Excel", ["Phân tích yêu cầu", "Lập trình viên", "Soạn tài liệu"]),
]
AUDIT_EVENTS = [
("tool_call", "read_file", True, "app/models/station.py (2.1 KB)"),
("tool_call", "write_file", True, "app/api/stations.py — tạo mới, 84 dòng"),
("tool_call", "run_command", True, "pytest tests/test_stations.py → 4 passed"),
("tool_call", "fetch_url", True, "https://docs.python.org/3/library/asyncio.html"),
("permission", "run_command", True, "Người dùng duyệt: npm install --save-dev vitest"),
("permission", "write_file", False, "Người dùng từ chối: ghi đè .env"),
("security_block", "path_outside_sandbox", False, "Chặn đọc C:\\Users\\NamPDT\\Documents\\personal.xlsx"),
("security_block", "network_blocked", False, "Chặn kết nối ra 203.0.113.44:8080 (không trong danh sách cho phép)"),
("security_block", "dangerous_command", False, "Chặn lệnh: rm -rf / --no-preserve-root"),
("security_block", "secret_in_output", False, "Phát hiện chuỗi giống API key trong đầu ra, đã che"),
("mcp_call", "filesystem.list_directory", True, "docs/iso → 148 mục"),
("mcp_call", "jira.search_issues", True, "project=EV AND status=Open → 23 issue"),
("mcp_call", "postgres.query", True, "SELECT count(*) FROM station_heartbeat → 1.284.902"),
("mcp_call", "jira.create_issue", False, "401 Unauthorized — API token hết hạn"),
("mcp_call", "filesystem.read_file", True, "docs/iso/9001-2015.pdf (4.2 MB)"),
]
MODELS = [("ollama", "qwen2.5-coder:7b"), ("ollama", "llama3.1:8b"), ("openai", "gpt-4o-mini")]
LABELS = ["Dựng API trạng thái trạm sạc", "Sửa lỗi tính tiền sai", "Gom số liệu doanh thu",
"Dựng slide trình bày", "Đánh chỉ mục ISO", "Rà soát bảo mật"]
def _iso(dt: datetime) -> str:
return dt.isoformat(timespec="seconds")
def seed(days: int = 45) -> dict:
"""Fill the (sandboxed) config dir. Returns a per-store count summary."""
from cowork_local.config import CONFIG_DIR
home = str(Path.home())
assert str(CONFIG_DIR).startswith(home), "refusing to seed outside the sandboxed HOME"
assert "cowork-capture-" in home or "cowork-seed-" in home, (
f"HOME ({home}) does not look like a capture sandbox — refusing to seed")
from cowork_local.core import admin_agents, co4e, history, projects, skills, tasks
out: dict[str, int] = {}
now = datetime.now().replace(hour=14, minute=32, second=0, microsecond=0)
# ---- projects ---------------------------------------------------------
made = []
for name, desc, instr in PROJECTS:
p = projects.new_project(name, description=desc, instructions=instr)
p.workspace_dir().mkdir(parents=True, exist_ok=True)
# a few files so the Folder tab's tree isn't bare
for rel in ("README.md", "src/main.py", "src/billing/session.py",
"tests/test_stations.py", "docs/ghi-chu.md"):
f = p.workspace_dir() / rel
f.parent.mkdir(parents=True, exist_ok=True)
if not f.exists():
f.write_text(f"# {rel}\n\n(nội dung mẫu cho ảnh chụp)\n", encoding="utf-8")
made.append(p)
out["projects"] = len(made)
# ---- conversations ----------------------------------------------------
hist_root = CONFIG_DIR / "history"
hist_root.mkdir(parents=True, exist_ok=True)
n_conv = 0
for i, (pi, title, pinned, msgs) in enumerate(CONVERSATIONS):
proj = made[pi]
created = _iso(now - timedelta(days=i * 2 + 1, hours=i * 3))
sid = (now - timedelta(days=i * 2 + 1)).strftime("%Y%m%d-%H%M%S-") + f"{i:03d}"
payload = [{"role": r, "content": c} for r, c in msgs]
for directory in (hist_root, proj.workspace_dir() / ".cowork_history"):
directory.mkdir(parents=True, exist_ok=True)
path = history.save_conversation(
directory, "cowork", sid, payload, title=title,
created=created, project_id=proj.project_id)
if pinned:
history.set_pinned(path, True)
# stagger mtime so the sidebar's newest-first order looks real
ts = (now - timedelta(days=i * 2 + 1)).timestamp()
os.utime(path, (ts, ts))
n_conv += 1
out["conversations"] = n_conv
# ---- scheduled tasks --------------------------------------------------
for title, status, prio, ai, enabled, off, last in TASKS:
t = tasks.new_task(
title=title, status=status, priority=prio, is_ai_generated=ai,
project_id=made[0].project_id, provider="ollama", model="qwen2.5-coder:7b",
description=f"Tác vụ tự động: {title.lower()}.",
schedule={"enabled": enabled,
"run_at": (now + timedelta(days=off)).strftime("%Y-%m-%d %H:%M"),
"repeat_type": "daily" if enabled else "none"},
logs={"last_status": last or "", "last_run_id": "run-demo" if last else "",
"last_error": "Chứng chỉ hết hạn 2026-08-06" if last == "failed" else ""},
)
if last:
t["runs"] = [{"run_id": f"r{n}", "status": last,
"finished_at": (now - timedelta(days=n)).strftime("%Y-%m-%d %H:%M"),
"error": "Chứng chỉ hết hạn" if last == "failed" else None}
for n in range(1, 4)]
tasks.save_task(t)
out["tasks"] = len(TASKS)
# ---- skills -----------------------------------------------------------
for name, desc, instr in SKILLS:
skills.save_skill(skills.Skill(name=name, description=desc,
instructions=instr, enabled=True))
out["skills"] = len(SKILLS)
# ---- Co4E agents ------------------------------------------------------
for name, role, icon, instr, sk in CO4E_AGENTS:
a = co4e.new_custom_agent(name)
a.role, a.icon, a.instructions, a.skills = role, icon, instr, sk
a.model = "qwen2.5-coder:7b"
co4e.save_custom_agent(a)
out["co4e_agents"] = len(CO4E_AGENTS)
# ---- Co4E workflows ---------------------------------------------------
wfs = []
for name, steps in WORKFLOWS:
wf = co4e.new_workflow(name)
prev = None
for j, label in enumerate(steps):
node = co4e.Node(id=co4e.new_node_id(), x=60.0 + j * 250, y=140.0 + (j % 2) * 120,
data=co4e.Step(label=label, role="AGENT",
instructions=f"{label}: thực hiện phần việc của mình "
f"rồi chuyển kết quả cho bước sau.",
model="qwen2.5-coder:7b"))
wf.nodes.append(node)
if prev:
wf.edges.append(co4e.Edge(id=co4e.new_edge_id(prev, node.id),
source=prev, target=node.id))
prev = node.id
co4e.save_workflow(wf)
wfs.append(wf)
out["workflows"] = len(wfs)
# ---- Co4E run history (no public save — written directly) -------------
runs = []
specs = [("done", 5, 5, 0), ("done", 2, 2, 1), ("error", 3, 5, 2),
("done", 3, 3, 3), ("stopped", 1, 5, 4), ("done", 5, 5, 6)]
for k, (status, done, total, ago) in enumerate(specs, 1):
wf = wfs[k % len(wfs)]
runs.append({
"id": f"run{k}", "wf_id": wf.id, "name": wf.name,
"total": total, "done": done, "status": status,
"plan_mode": False, "manual": False, "created_by": "local",
"created_at": (now - timedelta(days=ago, hours=k)).strftime("%Y-%m-%d %H:%M"),
"error": "Bước “Kiểm thử” trả về mã lỗi 1" if status == "error" else "",
"node_status": {n.id: ("done" if i < done else
("error" if status == "error" and i == done else "idle"))
for i, n in enumerate(wf.nodes)},
"wf": co4e.workflow_to_dict(wf),
"out_dir": str(made[0].workspace_dir()),
"project_id": made[0].project_id,
})
hp = CONFIG_DIR / "co4e" / "run_history.json"
hp.parent.mkdir(parents=True, exist_ok=True)
hp.write_text(json.dumps({"runs": runs}, ensure_ascii=False, indent=2), encoding="utf-8")
out["co4e_runs"] = len(runs)
# ---- usage day files (record() cannot backdate) -----------------------
usage_dir = CONFIG_DIR / "usage"
usage_dir.mkdir(parents=True, exist_ok=True)
n_usage = 0
for d in range(days):
day = now - timedelta(days=days - 1 - d)
# a workday rhythm: quiet weekends, a gentle upward trend
weekend = day.weekday() >= 5
turns = rnd.randint(1, 3) if weekend else rnd.randint(4, 11) + d // 12
lines = []
for _ in range(turns):
prov, model = rnd.choice(MODELS)
lines.append(json.dumps({
"ts": _iso(day.replace(hour=rnd.randint(8, 18), minute=rnd.randint(0, 59))),
"source": rnd.choice(["cowork", "cowork", "task", "co4e"]),
"label": rnd.choice(LABELS), "provider": prov, "model": model,
"in": rnd.randint(1200, 9000), "out": rnd.randint(300, 3200),
"cache": rnd.randint(0, 4200), "estimated": False,
"account": "local", "machine": "DESKTOP-DEMO",
}, ensure_ascii=False))
n_usage += 1
(usage_dir / f"{day:%Y-%m-%d}.jsonl").write_text("\n".join(lines) + "\n", encoding="utf-8")
out["usage_events"] = n_usage
# ---- audit day files (drives Security / MCP / Action tables) ----------
audit_dir = CONFIG_DIR / "audit"
audit_dir.mkdir(parents=True, exist_ok=True)
n_audit = 0
roles = ["cowork", "code", "schedule", "graphrag", "security"]
for d in range(14):
day = now - timedelta(days=13 - d)
lines = []
for _ in range(rnd.randint(4, 9)):
kind, name, ok, detail = rnd.choice(AUDIT_EVENTS)
lines.append(json.dumps({
"ts": _iso(day.replace(hour=rnd.randint(8, 19), minute=rnd.randint(0, 59))),
"kind": kind, "agent_role": rnd.choice(roles), "name": name,
"ok": ok, "detail": detail,
"account": "local", "role": "admin", "machine": "DESKTOP-DEMO",
}, ensure_ascii=False))
n_audit += 1
(audit_dir / f"{day:%Y-%m-%d}.jsonl").write_text("\n".join(lines) + "\n", encoding="utf-8")
out["audit_events"] = n_audit
# ---- admin agents -----------------------------------------------------
admin_dir = admin_agents.agents_admin_dir("")
admin_dir.mkdir(parents=True, exist_ok=True)
for name, kind in [("Trợ giúp trong app", "help"), ("Tìm kiếm tài khoản", "search"),
("Phân tích giám sát", "monitor"), ("Cowork mặc định", "cowork"),
("Hỏi đáp GraphRAG", "graphrag"), ("Lập lịch thông minh", "schedule"),
("Kiểm tra lệnh nguy hiểm", "security")]:
a = admin_agents.new_agent(name, task_kind=kind, provider="ollama",
model="qwen2.5-coder:7b", updated_by="local",
prompt=f"Bạn phụ trách chức năng “{kind}” của ứng dụng.")
admin_agents.save_agent(a, admin_dir)
out["admin_agents"] = 7
return out
if __name__ == "__main__":
raise SystemExit("Import and call seed() from capture_screens.py — it needs the sandboxed HOME.")