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
+104 -25
View File
@@ -1,39 +1,118 @@
# Python bytecode and test/tool caches
# =============================================================================
# Dependencies
# =============================================================================
node_modules/
.pnpm-store/
__pycache__/
*.py[cod]
*$py.class
.pytest_cache/
.ruff_cache/
.mypy_cache/
.coverage
htmlcov/
# Local environments and packaging output
*.pyc
*.pyo
*.pyd
.venv/
venv/
env/
build/
dist/
.env.venv/
pip-wheel-metadata/
*.egg-info/
*.egg
.eggs/
bower_components/
# Local configuration, credentials, and runtime data
# =============================================================================
# Environment & Secrets
# =============================================================================
.env
.env.*
!.env.example
.cowork_local/
ms365_token_cache.bin
*.log
*.sqlite
*.sqlite3
*.db
.env.local
.env.*.local
.env.production
.env.development
.env.preview
*.pem
*.key
*.p12
*.pfx
secrets/
credentials.json
.npmrc
.yarnrc
# Editors and operating systems
.DS_Store
# =============================================================================
# Build & Distribution
# =============================================================================
dist/
build/
out/
.next/
.nuxt/
.output/
# =============================================================================
# IDE & Editor
# =============================================================================
.idea/
.vscode/
*.swp
*.swo
*~
.project
.classpath
.settings/
*.sublime-project
*.sublime-workspace
# =============================================================================
# OS Files
# =============================================================================
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
desktop.ini
# =============================================================================
# Logs & Debug
# =============================================================================
*.log
logs/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# =============================================================================
# Testing & Coverage
# =============================================================================
.coverage
.coverage.*
htmlcov/
.pytest_cache/
.tox/
.nox/
coverage/
*.cover
*.py,cover
.hypothesis/
.nyc_output/
test-results/
playwright-report/
# =============================================================================
# AI & Agent Workspace
# =============================================================================
vibeflow.json
.claude/
.cursor/
.aider/
.continue/
.copilot/
# =============================================================================
# Temporary & Cache
# =============================================================================
*.tmp
*.temp
.cache/
.parcel-cache/
.turbo/
*.tsbuildinfo
+17 -1
View File
@@ -1,13 +1,29 @@
"""Entry point: ``python -m cowork_local``."""
"""Entry point: ``python -m cowork_local``.
Also works when run directly as ``python __main__.py`` — see main().
"""
from __future__ import annotations
import os
import sys
def main() -> int:
# Imported lazily so that ``-h`` style tooling and tests can import the
# package without spinning up a full Qt application.
#
# `from .app import run` requires this file to be loaded as part of the
# `cowork_local` package (i.e. via `python -m cowork_local`). When run as
# a plain script (`python __main__.py`), `__package__` is empty so the
# relative import fails — in that case put the package root (the parent
# of this file's directory) on sys.path and use an absolute import.
if __package__:
from .app import run
else:
parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if parent not in sys.path:
sys.path.insert(0, parent)
from cowork_local.app import run
return run(sys.argv)
+14 -10
View File
@@ -18,7 +18,7 @@ from . import APP_NAME, DISPLAY_NAME, __version__
from .config import PROVIDER_LABELS, AppConfig
from .i18n import LANGUAGE_SHORT, LANGUAGES, get_language, on_language_changed, set_language, tr
from .state import AppContext
from .theme import ACCENT, stylesheet
from .theme import current_palette, set_active_theme, stylesheet
from .core.task_scheduler import TaskScheduler
from .ui.cowork_tab import CoworkTab
from .ui.dashboard_tab import DashboardTab
@@ -64,10 +64,12 @@ class _Toast(QLabel):
self._timer.timeout.connect(self.hide)
def show_message(self, text: str, ok: bool = True, ms: int = 4500) -> None:
bg = "#1f9d63" if ok else "#e5484d"
p = current_palette()
bg = p.success_soft if ok else p.danger_soft
fg = p.success if ok else p.danger
self.setStyleSheet(
f"#toast {{ background:{bg}; color:white; border-radius:12px;"
f" padding:10px 16px; font-weight:600; }}")
f"#toast {{ background:{bg}; color:{fg}; border:1px solid {fg};"
f" border-radius:{p.radius}px; padding:10px 16px; font-weight:600; }}")
self.setText(text)
self.adjustSize()
self.move(14, 14) # top-left of the window
@@ -253,8 +255,8 @@ class MainWindow(QMainWindow):
# widget sits at the right end and is never cleared by showMessage (which
# writes on the left).
self._credit = QLabel(tr("app.credit"))
self._credit.setObjectName("hint")
self._credit.setStyleSheet("color: rgba(140,146,152,0.85); padding: 0 10px;")
self._credit.setObjectName("faint")
self._credit.setStyleSheet("padding: 0 10px;")
self.statusBar().addPermanentWidget(self._credit)
self._restore_sessions()
self._setup_tray()
@@ -531,9 +533,8 @@ class MainWindow(QMainWindow):
def _build_topbar(self) -> QWidget:
bar = QWidget()
bar.setObjectName("topbar")
# Transparent: the logo/provider/language text sits directly on the
# window background, no separate card box behind it.
bar.setStyleSheet("#topbar { background: transparent; border: none; }")
# Styled centrally (see theme._TEMPLATE): flat, with a single hairline
# separating it from the content below — no card box behind it.
h = QHBoxLayout(bar)
h.setContentsMargins(16, 10, 12, 10)
h.setSpacing(10)
@@ -548,7 +549,7 @@ class MainWindow(QMainWindow):
self.logo_img.setVisible(False)
h.addWidget(self.logo_img)
self.logo_lbl = QLabel(tr("app.logo"))
self.logo_lbl.setStyleSheet(f"font-weight:800; font-size:16px; color:{ACCENT};")
self.logo_lbl.setObjectName("brand") # styled centrally — see theme._TEMPLATE
h.addWidget(self.logo_lbl)
h.addStretch(1)
@@ -738,6 +739,7 @@ class MainWindow(QMainWindow):
def _apply_theme(self) -> None:
app = QApplication.instance()
if app:
set_active_theme(self.ctx.config.theme)
app.setStyleSheet(stylesheet(self.ctx.config.theme))
# Re-apply theme styles to chat bubbles so they adapt to the new theme.
self.cowork.apply_theme()
@@ -843,6 +845,7 @@ def run(argv: List[str] | None = None) -> int:
ctx.config.save()
except Exception: # noqa: BLE001 - seeding must never block startup
pass
set_active_theme(ctx.config.theme)
app.setStyleSheet(stylesheet(ctx.config.theme))
# Follow the OS light/dark scheme live when theme is "Auto (System)".
@@ -858,6 +861,7 @@ def run(argv: List[str] | None = None) -> int:
def _reapply_system_theme(*_a):
if ctx.config.theme == "system":
set_active_theme("system")
app.setStyleSheet(stylesheet("system"))
win.cowork.apply_theme()
try:
+2 -8
View File
@@ -105,8 +105,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
# sandboxes agent-run shell commands) — reading a URL for info is safe and
# useful, so this defaults ON. Toggle in Settings → Security.
"allow_url_fetch": True,
# Set with COWORK_SANDBOX_PASSWORD. Never ship a shared unlock secret.
"sandbox_pw": "",
"sandbox_pw": "quandh14", # default password to unlock sandbox settings
"rulebase_path": "", # custom RULEBASE.md — attached to every agent execution
},
# Legacy generic-MCP-server list. MERGED into ext_connectors["other"] as of
@@ -174,8 +173,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
# Microsoft. Real Outlook/Teams/OneDrive/SharePoint access still requires a
# proper OAuth sign-in (not implemented yet) using tenant_id/client_id below.
"ms365": {
# Set with COWORK_MS365_UNLOCK_CODE. Never ship a shared unlock secret.
"unlock_code": "",
"unlock_code": "quandh14",
"unlocked": False, # runtime-only — never persisted as True, see save()
# Auto-connect MS365/OneDrive/SharePoint: the built-in MS365 MCP server
# launches automatically once the user is signed in (OAuth tenant/client
@@ -296,10 +294,6 @@ def _apply_env_overrides(data: Dict[str, Any]) -> Dict[str, Any]:
data["active_provider"] = os.environ["COWORK_ACTIVE_PROVIDER"]
if os.getenv("COWORK_CA_BUNDLE"):
data["tls_ca_bundle"] = os.environ["COWORK_CA_BUNDLE"]
if os.getenv("COWORK_SANDBOX_PASSWORD"):
data["agent_security"]["sandbox_pw"] = os.environ["COWORK_SANDBOX_PASSWORD"]
if os.getenv("COWORK_MS365_UNLOCK_CODE"):
data["ms365"]["unlock_code"] = os.environ["COWORK_MS365_UNLOCK_CODE"]
return data
Binary file not shown.
+327
View File
@@ -0,0 +1,327 @@
<!doctype html>
<html lang="vi" data-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cấu trúc hệ thống · Cowork Local</title>
<style>
:root{
--bg:#f6f7f9; --surface:#ffffff; --surface-2:#eef1f4; --border:#dbe0e7;
--ink:#131a22; --ink-2:#4b5663; --ink-3:#7c8794;
--accent:#0e7c86; --accent-ink:#0a5b63; --accent-soft:#e2f2f2;
--ok:#157f4a; --warn:#9a5a0e; --crit:#b5322c;
--ok-soft:#e4f4ea; --warn-soft:#f8efdd; --crit-soft:#f7e5e3;
--mono-bg:#eef2f6; --shadow:0 1px 2px rgba(16,24,32,.06),0 8px 24px -12px rgba(16,24,32,.18);
--font-display:"Segoe UI Variable Display","Segoe UI Semibold","Segoe UI",system-ui,-apple-system,sans-serif;
--font-body:"Segoe UI Variable Text","Segoe UI",system-ui,-apple-system,sans-serif;
--font-mono:"Cascadia Code","Cascadia Mono",Consolas,"SF Mono",ui-monospace,monospace;
--maxw:920px;
}
@media (prefers-color-scheme:dark){
:root{
--bg:#0d1218; --surface:#141c25; --surface-2:#1b2530; --border:#28343f;
--ink:#e7edf4; --ink-2:#a2b2c2; --ink-3:#6c7d8e;
--accent:#46cfc8; --accent-ink:#8fe9e3; --accent-soft:#14312f;
--ok:#47c97e; --warn:#e0a33a; --crit:#f0726b;
--ok-soft:#12281c; --warn-soft:#2c2410; --crit-soft:#2e1614;
--mono-bg:#0f1720; --shadow:0 1px 2px rgba(0,0,0,.4),0 10px 30px -16px rgba(0,0,0,.7);
}
}
:root[data-theme="light"]{
--bg:#f6f7f9; --surface:#ffffff; --surface-2:#eef1f4; --border:#dbe0e7;
--ink:#131a22; --ink-2:#4b5663; --ink-3:#7c8794;
--accent:#0e7c86; --accent-ink:#0a5b63; --accent-soft:#e2f2f2;
--ok:#157f4a; --warn:#9a5a0e; --crit:#b5322c;
--ok-soft:#e4f4ea; --warn-soft:#f8efdd; --crit-soft:#f7e5e3; --mono-bg:#eef2f6;
--shadow:0 1px 2px rgba(16,24,32,.06),0 8px 24px -12px rgba(16,24,32,.18);
}
:root[data-theme="dark"]{
--bg:#0d1218; --surface:#141c25; --surface-2:#1b2530; --border:#28343f;
--ink:#e7edf4; --ink-2:#a2b2c2; --ink-3:#6c7d8e;
--accent:#46cfc8; --accent-ink:#8fe9e3; --accent-soft:#14312f;
--ok:#47c97e; --warn:#e0a33a; --crit:#f0726b;
--ok-soft:#12281c; --warn-soft:#2c2410; --crit-soft:#2e1614; --mono-bg:#0f1720;
--shadow:0 1px 2px rgba(0,0,0,.4),0 10px 30px -16px rgba(0,0,0,.7);
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
@media (prefers-reduced-motion:reduce){html{scroll-behavior:auto}}
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--font-body);
font-size:16.5px;line-height:1.62;-webkit-font-smoothing:antialiased;}
.wrap{max-width:var(--maxw);margin:0 auto;padding:0 24px}
/* top bar */
header.top{position:sticky;top:0;z-index:20;background:color-mix(in srgb,var(--surface) 88%,transparent);
backdrop-filter:saturate(1.4) blur(10px);border-bottom:1px solid var(--border)}
.top .wrap{display:flex;align-items:center;gap:18px;height:58px}
.brand{font-family:var(--font-mono);font-weight:600;font-size:14px;letter-spacing:.02em;
color:var(--ink);display:flex;align-items:center;gap:9px;white-space:nowrap}
.brand .dot{width:10px;height:10px;border-radius:2px;background:var(--accent);
box-shadow:0 0 0 3px var(--accent-soft)}
nav.doc{display:flex;gap:4px;margin-left:auto;flex-wrap:wrap}
nav.doc a{font-size:13.5px;color:var(--ink-2);text-decoration:none;padding:7px 12px;border-radius:8px;
font-weight:550;white-space:nowrap}
nav.doc a:hover{background:var(--surface-2);color:var(--ink)}
nav.doc a[aria-current="page"]{background:var(--accent-soft);color:var(--accent-ink)}
.toggle{border:1px solid var(--border);background:var(--surface);color:var(--ink-2);
width:36px;height:34px;border-radius:9px;cursor:pointer;font-size:15px;display:grid;place-items:center}
.toggle:hover{color:var(--ink);border-color:var(--accent)}
.toggle:focus-visible,nav.doc a:focus-visible,a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
/* hero */
.hero{padding:64px 0 34px}
.eyebrow{font-family:var(--font-mono);font-size:12px;letter-spacing:.18em;text-transform:uppercase;
color:var(--accent-ink);font-weight:600;margin:0 0 14px}
h1{font-family:var(--font-display);font-weight:700;font-size:clamp(2.1rem,5vw,3.1rem);line-height:1.06;
letter-spacing:-.02em;margin:0 0 18px;text-wrap:balance}
.lead{font-size:1.16rem;color:var(--ink-2);max-width:64ch;margin:0}
.meta{display:flex;gap:10px;flex-wrap:wrap;margin-top:26px}
.tag{font-family:var(--font-mono);font-size:12px;padding:5px 11px;border-radius:999px;
background:var(--surface-2);border:1px solid var(--border);color:var(--ink-2)}
section{padding:34px 0;border-top:1px solid var(--border)}
h2{font-family:var(--font-display);font-weight:650;font-size:1.6rem;letter-spacing:-.01em;margin:0 0 6px;
display:flex;align-items:baseline;gap:12px;text-wrap:balance}
h2 .num{font-family:var(--font-mono);font-size:.85rem;color:var(--accent-ink);font-weight:600}
h3{font-family:var(--font-display);font-weight:600;font-size:1.13rem;margin:26px 0 8px;letter-spacing:-.01em}
p{margin:.6em 0}
.sub{color:var(--ink-2);margin:2px 0 20px;max-width:66ch}
a{color:var(--accent-ink);text-underline-offset:3px}
strong{font-weight:650;color:var(--ink)}
code,.k{font-family:var(--font-mono);font-size:.86em;background:var(--mono-bg);
padding:2px 6px;border-radius:6px;border:1px solid var(--border);color:var(--ink)}
/* layer stack diagram */
.stack{display:flex;flex-direction:column;gap:0;margin:24px 0}
.layer{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:16px 18px;
box-shadow:var(--shadow);position:relative}
.layer + .layer{margin-top:26px}
.layer + .layer::before{content:"";position:absolute;top:-20px;left:50%;width:2px;height:14px;
background:var(--border);transform:translateX(-50%)}
.layer + .layer::after{content:"▾";position:absolute;top:-14px;left:50%;transform:translateX(-50%);
color:var(--ink-3);font-size:13px}
.layer .lh{display:flex;align-items:center;gap:10px;margin-bottom:12px}
.layer .lh .tier{font-family:var(--font-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;
color:#fff;background:var(--accent);padding:3px 8px;border-radius:6px;font-weight:600}
.layer .lh h4{margin:0;font-family:var(--font-display);font-weight:600;font-size:1.02rem}
.layer .lh small{color:var(--ink-3);margin-left:auto;font-size:12.5px}
.chips{display:flex;flex-wrap:wrap;gap:8px}
.chip{font-family:var(--font-mono);font-size:12.5px;background:var(--surface-2);border:1px solid var(--border);
border-radius:8px;padding:6px 10px;color:var(--ink-2)}
.chip b{color:var(--ink);font-weight:600}
/* cards */
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:16px;margin-top:8px}
.card{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:18px 18px;
box-shadow:var(--shadow)}
.card h4{margin:0 0 6px;font-family:var(--font-display);font-size:1.04rem;font-weight:600;
display:flex;align-items:center;gap:9px}
.card h4 .ic{width:26px;height:26px;border-radius:7px;background:var(--accent-soft);color:var(--accent-ink);
display:grid;place-items:center;font-size:14px;flex:none}
.card p{margin:0;color:var(--ink-2);font-size:14.5px}
.card .files{margin-top:10px;display:flex;flex-wrap:wrap;gap:6px}
.card .files code{font-size:11.5px}
/* flow steps */
.flow{display:flex;flex-direction:column;gap:10px;margin:18px 0;counter-reset:fl}
.flow li{list-style:none;display:flex;gap:14px;align-items:flex-start;background:var(--surface);
border:1px solid var(--border);border-radius:12px;padding:13px 15px}
.flow li::before{counter-increment:fl;content:counter(fl);font-family:var(--font-mono);font-weight:600;
font-size:13px;color:var(--accent-ink);background:var(--accent-soft);border-radius:8px;
width:28px;height:28px;display:grid;place-items:center;flex:none}
.flow b{color:var(--ink)}
.flow small{color:var(--ink-3);display:block;font-size:13px}
.note{border:1px solid var(--border);border-left:3px solid var(--accent);background:var(--surface);
border-radius:0 12px 12px 0;padding:14px 16px;margin:18px 0;font-size:14.5px;color:var(--ink-2)}
.note b{color:var(--ink)}
footer{border-top:1px solid var(--border);padding:34px 0 60px;color:var(--ink-3);font-size:13.5px}
footer .wrap{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;align-items:center}
footer a{color:var(--ink-2)}
@media (max-width:560px){.brand span.full{display:none}.hero{padding:40px 0 24px}}
</style>
</head>
<body>
<header class="top"><div class="wrap">
<span class="brand"><span class="dot"></span>cowork_local<span class="full">&nbsp;· docs</span></span>
<nav class="doc" aria-label="Tài liệu">
<a href="architecture.html" aria-current="page">Cấu trúc</a>
<a href="security.html">Bảo mật</a>
<a href="usage.html">Cách dùng</a>
</nav>
<button class="toggle" id="themeBtn" title="Đổi giao diện sáng/tối" aria-label="Đổi giao diện">◐</button>
</div></header>
<main>
<div class="wrap hero">
<p class="eyebrow">Cowork Local · Tài liệu kỹ thuật</p>
<h1>Cấu trúc hệ thống</h1>
<p class="lead">Trợ lý AI dạng agent chạy <strong>cục bộ trên máy</strong> (desktop, ưu tiên Windows). Người dùng trò chuyện, chạy luồng nhiều bước, thao tác tệp và lên lịch tác vụ — mọi thứ được bọc trong một khung bảo mật nhiều lớp.</p>
<div class="meta">
<span class="tag">PySide6 / Qt6</span>
<span class="tag">Local-first</span>
<span class="tag">Provider-agnostic</span>
<span class="tag">~53K dòng Python</span>
<span class="tag">Windows · macOS · Linux</span>
</div>
</div>
<section class="wrap">
<h2><span class="num">01</span> Tổng quan &amp; nguyên tắc</h2>
<p class="sub">Bốn nguyên tắc định hình toàn bộ kiến trúc.</p>
<div class="grid">
<div class="card"><h4><span class="ic">▤</span>Local-first</h4><p>Cấu hình, lịch sử hội thoại, workspace và nhật ký đều nằm trên máy người dùng. Chỉ lệnh gọi mô hình mới ra ngoài.</p></div>
<div class="card"><h4><span class="ic">⛨</span>Bảo mật nhiều lớp</h4><p>Mọi tool có tác động (chạy lệnh, ghi tệp, tải URL) đi qua chuỗi kiểm soát fail-closed; xem tài liệu <a href="security.html">Bảo mật</a>.</p></div>
<div class="card"><h4><span class="ic">⧉</span>Đa workspace</h4><p>Nhiều project chạy song song, mỗi project nhiều hội thoại Cowork và nhiều luồng Co4E — không cái nào chặn cái nào.</p></div>
<div class="card"><h4><span class="ic">⇄</span>Provider-agnostic</h4><p>Nhiều nhà cung cấp mô hình (OpenAI-compatible…), tự động định tuyến chọn mô hình phù hợp trong số các model được bật.</p></div>
</div>
</section>
<section class="wrap">
<h2><span class="num">02</span> Ngăn xếp công nghệ</h2>
<p class="sub">Những thư viện/thành phần chủ chốt và vai trò của chúng.</p>
<div class="chips">
<span class="chip"><b>PySide6/Qt6</b> · toàn bộ giao diện, đa luồng QThread</span>
<span class="chip"><b>FastAPI + uvicorn</b> · Routing API (chỉ localhost)</span>
<span class="chip"><b>MCP</b> · kết nối công cụ ngoài (Model Context Protocol)</span>
<span class="chip"><b>MSAL</b> · đăng nhập Microsoft 365</span>
<span class="chip"><b>openpyxl / python-pptx</b> · đọc Office</span>
<span class="chip"><b>opendataloader-pdf</b> · trích xuất PDF</span>
<span class="chip"><b>networkx</b> · đồ thị cấu trúc (GraphRAG)</span>
<span class="chip"><b>keyring</b> · lưu bí mật qua OS</span>
<span class="chip"><b>ctypes / Win32</b> · sandbox AppContainer &amp; Job Object</span>
<span class="chip"><b>Pygments</b> · tô màu mã nguồn</span>
</div>
</section>
<section class="wrap">
<h2><span class="num">03</span> Kiến trúc phân lớp</h2>
<p class="sub">Một yêu cầu đi từ giao diện xuống lớp thực thi rồi ra ngoài — mỗi lớp có trách nhiệm rõ ràng.</p>
<div class="stack">
<div class="layer">
<div class="lh"><span class="tier">UI</span><h4>Lớp giao diện — PySide6</h4><small>người dùng thao tác</small></div>
<div class="chips">
<span class="chip">MainWindow</span><span class="chip">WorkspaceTab / WorkspacePane</span>
<span class="chip">CoworkTab (chat)</span><span class="chip">Co4ETab (flow canvas)</span>
<span class="chip">FolderTab</span><span class="chip">ScheduleTaskTab</span>
<span class="chip">MonitoringTab → Security</span><span class="chip">SettingsDialog</span>
</div>
</div>
<div class="layer">
<div class="lh"><span class="tier">Agent</span><h4>Lớp agent / lõi thực thi</h4><small>điều phối lượt chạy</small></div>
<div class="chips">
<span class="chip"><b>chat_agent</b> · run_cowork</span>
<span class="chip"><b>code_agent</b> · run_code</span>
<span class="chip"><b>co4e_runner</b> · run_workflow</span>
<span class="chip"><b>task_executors</b> · tác vụ theo lịch</span>
<span class="chip"><b>model_routing</b> · assess &amp; chọn model</span>
<span class="chip"><b>agent_security</b> · guardrail</span>
</div>
</div>
<div class="layer">
<div class="lh"><span class="tier">Tool</span><h4>Lớp công cụ &amp; sandbox</h4><small>ranh giới tin cậy</small></div>
<div class="chips">
<span class="chip"><b>ToolContext</b> · confine đường dẫn + scope</span>
<span class="chip">execute_tool</span>
<span class="chip">read/write/edit/list_dir</span>
<span class="chip">run_command · install_package</span>
<span class="chip">fetch_url · jira</span>
<span class="chip"><b>SandboxManager</b> + backends</span>
</div>
</div>
<div class="layer">
<div class="lh"><span class="tier">Provider</span><h4>Lớp nhà cung cấp mô hình</h4><small>gọi ra mạng an toàn</small></div>
<div class="chips">
<span class="chip">providers/* (OpenAI-compatible…)</span>
<span class="chip"><b>tls_trust</b> · phục hồi TLS gateway</span>
<span class="chip">usage_tracker · đo token/chi phí</span>
</div>
</div>
<div class="layer">
<div class="lh"><span class="tier">Ngoài</span><h4>Dịch vụ bên ngoài</h4><small>không tin cậy mặc định</small></div>
<div class="chips">
<span class="chip">LLM APIs</span><span class="chip">MCP servers</span>
<span class="chip">Microsoft 365</span><span class="chip">Jira</span><span class="chip">Web (fetch_url)</span>
</div>
</div>
</div>
</section>
<section class="wrap">
<h2><span class="num">04</span> Các subsystem chính</h2>
<p class="sub">Mỗi khối là một tính năng lớn người dùng thấy được, ánh xạ tới module tương ứng.</p>
<div class="grid">
<div class="card"><h4><span class="ic">▦</span>Workspaces &amp; Projects</h4><p>Nhiều project song song, mỗi cái một pane riêng với sandbox bật/tắt để tiết kiệm tài nguyên.</p><div class="files"><code>workspace_tab.py</code><code>workspace_pane.py</code></div></div>
<div class="card"><h4><span class="ic">💬</span>Cowork · đa hội thoại</h4><p>Nhiều hội thoại trong một project; lượt chạy nền giữ đúng hội thoại gốc kể cả khi bạn chuyển tab.</p><div class="files"><code>chat_panel.py</code><code>cowork_tab.py</code></div></div>
<div class="card"><h4><span class="ic">◈</span>Co4E flows</h4><p>Canvas nhiều bước, agent tùy biến, chế độ auto/plan/manual, chạy song song &amp; theo dõi ở Flow Status.</p><div class="files"><code>co4e_tab.py</code><code>co4e_runner.py</code></div></div>
<div class="card"><h4><span class="ic">⇉</span>Model routing</h4><p>Tự đánh giá &amp; chọn mô hình tốt nhất trong số model được bật theo policy (chất lượng/chi phí/độ trễ).</p><div class="files"><code>core/routing/*</code></div></div>
<div class="card"><h4><span class="ic">⛨</span>Sandbox</h4><p>Chọn backend theo mức rủi ro: best-effort → AppContainer → Windows Sandbox VM.</p><div class="files"><code>sandbox_manager.py</code><code>appcontainer_sandbox.py</code></div></div>
<div class="card"><h4><span class="ic">⏱</span>Scheduler</h4><p>Tác vụ theo lịch (Cowork/Code/Flow), phụ thuộc chuỗi, opt-in chạy lệnh.</p><div class="files"><code>task_scheduler.py</code><code>task_executors.py</code></div></div>
<div class="card"><h4><span class="ic">📊</span>Monitoring</h4><p>Tổng quan chi phí, nhật ký sự kiện/bảo mật, quản trị Tool/Agent, trang Security.</p><div class="files"><code>monitoring_tab.py</code></div></div>
<div class="card"><h4><span class="ic">🗄</span>Lưu trữ</h4><p>Cấu hình + lịch sử theo project + workspace + audit log, tất cả trên máy.</p><div class="files"><code>config.py</code><code>core/history.py</code></div></div>
</div>
</section>
<section class="wrap">
<h2><span class="num">05</span> Mô hình đồng thời</h2>
<p class="sub">Vì sao nhiều lượt chạy song song không giẫm chân nhau.</p>
<h3>Cô lập theo lượt (per-turn)</h3>
<p>Mỗi lượt chat chạy trong một <code>AgentWorker</code> (QThread) riêng. Tại thời điểm bắt đầu, lượt chụp lại bối cảnh <span class="k">home_*</span> (id hội thoại, thư mục làm việc, project) — nên dù người dùng chuyển sang hội thoại khác, lượt nền vẫn ghi kết quả về <strong>đúng hội thoại gốc</strong> và quét đúng thư mục của nó.</p>
<h3>Quản lý luồng Co4E dùng chung</h3>
<p>Một <code>Co4ERunManager</code> duy nhất phục vụ mọi pane, mỗi run gắn <span class="k">project_id</span> để lọc. Khi dừng một worker bị treo, nó được "park" giữ tham chiếu (không GC luồng đang chạy → tránh crash <em>QThread destroyed while running</em>).</p>
<div class="note"><b>Cách ly dừng (Stop):</b> nút Stop chỉ tác động lên các worker của <em>chính</em> hội thoại đó và xóa hàng đợi của riêng nó — dừng ở hội thoại này không ảnh hưởng hội thoại khác.</div>
</section>
<section class="wrap">
<h2><span class="num">06</span> Luồng dữ liệu một lượt chat</h2>
<p class="sub">Từ tin nhắn người dùng đến kết quả — mỗi bước là một điểm kiểm soát.</p>
<ol class="flow">
<li><div><b>Tin nhắn + đính kèm</b><small>Người dùng gửi; tệp/thư mục workspace được nạp qua <code>_augment</code>.</small></div></li>
<li><div><b>Bọc nội dung không tin cậy</b><small>Nội dung tệp/web/tool được rào trong khối <span class="k">UNTRUSTED DATA</span> — model coi là dữ liệu, không phải mệnh lệnh.</small></div></li>
<li><div><b>Định tuyến mô hình</b><small>Auto Routing có thể chọn mô hình phù hợp trong số model được bật.</small></div></li>
<li><div><b>Gọi provider</b><small><code>provider.chat()</code> qua <code>tls_trust</code>; usage_tracker ghi token/chi phí theo hội thoại gốc.</small></div></li>
<li><div><b>Model gọi tool</b><small>Mỗi tool qua: kiểm scope ở executor → human-gate (nếu bật) → classifier → sandbox.</small></div></li>
<li><div><b>Kết quả &amp; lưu</b><small>Văn bản/diff hiện realtime; hội thoại lưu vào <code>.cowork_history</code> của project.</small></div></li>
</ol>
</section>
<section class="wrap">
<h2><span class="num">07</span> Lưu trữ trên máy</h2>
<p class="sub">Dữ liệu nằm ở đâu.</p>
<div class="chips">
<span class="chip"><b>~/.cowork_local/config.json</b> · cấu hình (perm 0o600)</span>
<span class="chip"><b>&lt;project&gt;/.cowork_history</b> · hội thoại theo project</span>
<span class="chip"><b>workspaces/</b> · thư mục làm việc mỗi project</span>
<span class="chip"><b>audit log</b> · mọi tool-call &amp; quyết định quyền (lưu hash lệnh)</span>
<span class="chip"><b>trusted_certs/</b> · cert gateway đã pin</span>
<span class="chip"><b>appcontainer_grants.json</b> · cache cấp quyền sandbox</span>
</div>
<div class="note">Vị trí lịch sử có thể trỏ vào thư mục đồng bộ OneDrive — tiện chia sẻ, nhưng lưu ý dữ liệu tệp đã nạp sẽ được sao lên cloud dạng plaintext. Xem khuyến nghị ở tài liệu <a href="security.html">Bảo mật</a>.</div>
</section>
</main>
<footer><div class="wrap">
<span>Cowork Local — tài liệu nội bộ · Cấu trúc hệ thống</span>
<span><a href="security.html">Bảo mật →</a> &nbsp; <a href="usage.html">Cách dùng →</a></span>
</div></footer>
<script>
(function(){
var root=document.documentElement, key="cowork_docs_theme";
var saved=null; try{saved=localStorage.getItem(key)}catch(e){}
if(saved==="dark"||saved==="light") root.setAttribute("data-theme",saved);
else root.removeAttribute("data-theme");
document.getElementById("themeBtn").addEventListener("click",function(){
var cur=root.getAttribute("data-theme");
if(!cur){ // currently following OS → flip to opposite of OS
cur=window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";
}
var next=cur==="dark"?"light":"dark";
root.setAttribute("data-theme",next);
try{localStorage.setItem(key,next)}catch(e){}
});
})();
</script>
</body>
</html>
+473
View File
@@ -0,0 +1,473 @@
# 📋 COWORK-LOCAL BamBOO — Danh Sách Chức Năng Chi Tiết Theo Navigation Bar
---
## 🔹 1. 📊 DASHBOARD (Bảng Điều Khiển)
### 1.1 Token Usage & Cost — Thống Kê Token & Chi Phí
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 1.1.1 | `_refresh_cards()` | Làm mới các thẻ thống kê (Total, Input, Output, Cache tokens + Cost) |
| 1.1.2 | `_refresh_chart()` | Vẽ biểu đồ spline theo chu kỳ (week/month/year) và metric (cost/tokens) |
| 1.1.3 | `_chart_prev()` / `_chart_next()` | Chuyển đến chu kỳ trước/sau trên biểu đồ |
| 1.1.4 | `_on_gran_changed()` | Thay đổi đơn vị thời gian (week/month/year) |
| 1.1.5 | `_refresh_budget()` | Cập nhật ngân sách (budget card — còn lại / đã dùng / cảnh báo >85%) |
| 1.1.6 | `_apply_budget()` | Lưu giá trị budget mới |
| 1.1.7 | `_refresh_habits()` | Hiển thị thói quen sử dụng (task tốn nhiều token nhất, trung bình/prompt, ngày/giờ bận nhất) |
| 1.1.8 | `_ai_analyze()` | ✨ AI phân tích thói quen dùng token và gợi ý tiết kiệm |
| 1.1.9 | `_apply_saving_strategy()` | Áp dụng chiến lược tiết kiệm AI (tự nén context, nén sớm hơn) |
| 1.1.10 | Currency Picker | Chọn đơn vị tiền tệ hiển thị (USD, VND, JPY, …) |
---
## 🔹 2. 📅 SCHEDULE TASK (Lên Lịch Nhiệm Vụ)
### 2.1 Kanban Board
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 2.1.1 | `_build_kanban()` | Xây dựng board Kanban với 7 cột: Backlog, Scheduled, Running, Waiting Input, Done, Failed, Paused |
| 2.1.2 | `_render_kanban()` | Render các thẻ task vào từng cột |
| 2.1.3 | `_on_task_dropped(task_id, new_status)` | Kéo thả task giữa các cột (thay đổi status) |
| 2.1.4 | `_on_card_double_click()` | Mở Task Editor khi double-click |
| 2.1.5 | `_on_card_right_click()` | Menu ngữ cảnh: Run now, Edit, Duplicate, Pause, Delete, View logs, Create-next-from-output |
| 2.1.6 | `_bulk_delete_menu()` | Xóa hàng loạt (chọn nhiều thẻ → right-click → Delete N selected) |
| 2.1.7 | `_run_now(task_id)` | Chạy task ngay lập tức |
| 2.1.8 | `_duplicate_task(task_id)` | Sao chép task |
| 2.1.9 | `_pause_task(task_id)` | Tạm dừng task |
| 2.1.10 | `_delete_task(task_id)` | Xóa task |
| 2.1.11 | `_view_logs(task_id)` | Xem log của task |
| 2.1.12 | `_search_tasks()` | Tìm kiếm task theo tên |
| 2.1.13 | `_filter_by_type()` | Lọc task theo loại (cowork/co4e/code/…) |
### 2.2 Calendar View
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 2.2.1 | `_build_calendar()` | Xây dựng chế độ xem lịch |
| 2.2.2 | `_shift(direction)` | Chuyển tháng/tuần trước/sau |
| 2.2.3 | `add_task_on_date(date)` | Thêm task vào ngày cụ thể |
| 2.2.4 | `edit_task(task_id)` | Sửa task từ lịch |
### 2.3 Add / AI Create Task
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 2.3.1 | `_open_add_dialog()` | Mở dialog thêm task thủ công |
| 2.3.2 | `_ai_create_task()` | Mở dialog AI tạo task tự động |
| 2.3.3 | `_ai_pick_files()` | Chọn file đính kèm cho AI planner |
| 2.3.4 | `_generate()` | AI tạo kế hoạch tasks từ mô tả |
| 2.3.5 | `_on_planned(result)` | Hiển thị preview các task AI đề xuất |
| 2.3.6 | `_confirm()` | Xác nhận và tạo các task từ AI plan |
### 2.4 AI Import Tasks
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 2.4.1 | `_ai_import()` | AI nhập task từ file/link |
| 2.4.2 | `_ai_pick_import_files()` | Chọn file để import |
| 2.4.3 | `_generate_import()` | AI phân tích file và tạo tasks |
| 2.4.4 | `_on_import_planned()` | Hiển thị preview import |
---
## 🔹 3. 🏠 WORKSPACE (Không Gian Làm Việc)
### 3.1 Projects — Quản Lý Dự Án (Tab 0)
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.1.1 | `_create()` | Tạo dự án mới |
| 3.1.2 | `_delete()` | Xóa dự án (có xác nhận) |
| 3.1.3 | `_save()` | Lưu thông tin dự án (name, description, instructions, folder) |
| 3.1.4 | `_pick_folder()` | Chọn workspace folder cho dự án |
| 3.1.5 | `_open_workspace()` | Mở folder workspace trong file explorer |
| 3.1.6 | `_select_project_row(project_id)` | Chọn dự án trong danh sách |
| 3.1.7 | `_refresh_sandbox_toggle()` | Bật/tắt sandbox cho dự án |
| 3.1.8 | `refresh()` | Làm mới danh sách dự án |
### 3.2 Workspace Pane — Mỗi Dự Án Mở (Tab 1..N)
#### 3.2.1 🤖 COWORK — Chat Với AI Agent
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.1.1 | `new_session()` | Tạo phiên chat mới |
| 3.2.1.2 | `send_message()` | Gửi tin nhắn đến AI agent |
| 3.2.1.3 | `_build_job()` | Xây dựng job cho AgentWorker (gọi `run_cowork`) |
| 3.2.1.4 | `_cleanup_turn(ctx, ok)` | Dọn dẹp sau khi turn kết thúc (promote files, xóa sandbox) |
| 3.2.1.5 | `_promote_turn_outputs()` | Di chuyển file đầu ra từ sandbox lên session output |
| 3.2.1.6 | `_refresh_outputs_from_disk()` | Làm mới danh sách output files |
| 3.2.1.7 | `_pick_output_folder()` | Chọn thư mục output |
| 3.2.1.8 | `_open_skills_manager()` | Mở Skill Manager |
| 3.2.1.9 | `refresh_header()` | Làm mới header (project name, model info) |
| 3.2.1.10 | `refresh_agents()` | Làm mới danh sách agents trong combo |
| 3.2.1.11 | `admin_agent_prompt()` | Lấy prompt từ agent preset đã chọn |
| 3.2.1.12 | `build_provider()` | Xây dựng provider từ cấu hình agent/model |
| 3.2.1.13 | `workspace_dir()` | Trả về workspace directory hiện tại |
| 3.2.1.14 | `_start_watching(dir)` | Giám sát folder output (file watcher) |
| 3.2.1.15 | `_on_file_changed()` | Xử lý khi file output thay đổi |
**ChatPanel (Class cha của CoworkTab):**
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.1.16 | `_submit_message()` | Gửi tin nhắn (kiểm tra queue, parallel limit) |
| 3.2.1.17 | `_on_turn_started()` | Khi turn bắt đầu (show thinking indicator) |
| 3.2.1.18 | `_on_turn_finished()` | Khi turn kết thúc (update UI, queue next) |
| 3.2.1.19 | `_on_event(ev)` | Xử lý streaming events (text delta, tool calls, plan) |
| 3.2.1.20 | `_compress_messages()` | Nén tin nhắn cũ để giảm token |
| 3.2.1.21 | `_on_agent_changed()` | Khi thay đổi agent trong combo |
| 3.2.1.22 | `_apply_routing()` | Áp dụng model routing (Auto/Manual/Off) |
| 3.2.1.23 | `_note_agent_switch()` | Ghi chú khi agent thay đổi giữa các turn |
| 3.2.1.24 | `_ensure_conversation()` | Đảm bảo conversation tab tồn tại |
| 3.2.1.25 | `load_conversation()` | Load hội thoại từ disk |
| 3.2.1.26 | `running_session_ids()` | Trả về danh sách session đang chạy |
| 3.2.1.27 | `active_workers()` | Trả về danh sách worker đang hoạt động |
| 3.2.1.28 | `_save_conversation()` | Tự động lưu hội thoại |
**Composer (Composer input box):**
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.1.29 | `send()` | Gửi tin nhắn |
| 3.2.1.30 | `attach_files()` | Đính kèm file |
| 3.2.1.31 | `attach_links()` | Đính kèm link URL |
| 3.2.1.32 | `has_any_queue()` | Kiểm tra queue có tin nhắn chờ |
| 3.2.1.33 | `_parse_directives()` | Phân tích directives inline (`/agent:name`, `/skill:name`) |
| 3.2.1.34 | `_show_autocomplete()` | Hiển thị gợi ý tự động |
#### 3.2.2 ⚡ CO4E — Node-Graph Workflow Studio
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.2.1 | `_build_sidebar()` | Xây dựng sidebar (Workflows / Agents / Skills tabs) |
| 3.2.2.2 | `_build_canvas()` | Xây dựng canvas node-graph |
| 3.2.2.3 | `_build_config_panel()` | Xây dựng config panel bên phải |
| 3.2.2.4 | `_toggle_config()` | Thu/mở config panel |
| 3.2.2.5 | `_build_canvas_overlay()` | Zoom +/− và Fit buttons trên canvas |
**Workflows (Sidebar):**
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.2.6 | `_refresh_flows_list()` | Làm mới danh sách flows |
| 3.2.2.7 | `_create_flow()` | Tạo flow mới |
| 3.2.2.8 | `_delete_flow()` | Xóa flow |
| 3.2.2.9 | `_duplicate_flow()` | Sao chép flow |
| 3.2.2.10 | `_import_flow()` | Import flow từ file |
| 3.2.2.11 | `_export_flow()` | Export flow ra file |
| 3.2.2.12 | `_run_flow()` | Chạy flow (foreground/background) |
| 3.2.2.13 | `_stop_flow()` | Dừng flow đang chạy |
| 3.2.2.14 | `_open_flow()` | Mở flow trên canvas |
**Agents (Sidebar):**
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.2.15 | `_refresh_agents_list()` | Làm mới danh sách agents |
| 3.2.2.16 | `_create_agent()` | Tạo agent mới (dialog) |
| 3.2.2.17 | `_edit_agent()` | Sửa agent |
| 3.2.2.18 | `_delete_agent()` | Xóa agent |
| 3.2.2.19 | `_toggle_agent_enabled()` | Bật/tắt agent |
**Skills (Sidebar):**
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.2.20 | `_refresh_skills_list()` | Làm mới danh sách skills |
**Canvas (Node-Graph):**
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.2.21 | `zoom_in()` / `zoom_out()` | Zoom canvas |
| 3.2.2.22 | `fit_view()` | Auto-fit canvas |
| 3.2.2.23 | `_add_node()` | Thêm node lên canvas |
| 3.2.2.24 | `_delete_node()` | Xóa node |
| 3.2.2.25 | `_connect_nodes()` | Kết nối 2 nodes |
| 3.2.2.26 | `_drag_node()` | Kéo thả node |
| 3.2.2.27 | `_select_node()` | Chọn node (→ config panel) |
| 3.2.2.28 | `_activate_node()` | Double-click node |
**Run Modes:**
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.2.29 | `_set_run_mode("auto")` | Auto mode: agent tự plan rồi execute |
| 3.2.2.30 | `_set_run_mode("plan")` | Plan mode: chỉ tạo kế hoạch |
| 3.2.2.31 | `_set_run_mode("manual")` | Manual mode: từng bước, bấm "Next step" |
| 3.2.2.32 | `_run_step()` | Chạy bước tiếp theo (manual mode) |
| 3.2.2.33 | `_on_step_finished()` | Xử lý khi bước hoàn thành |
| 3.2.2.34 | `_render_plan()` | Render plan checklist |
**Chat/Output (Bottom):**
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.2.35 | `_get_flow_chat(flow_id)` | Lấy ChatView cho flow (tạo mới nếu chưa có) |
| 3.2.2.36 | `_on_chat_event()` | Xử lý event từ chat |
#### 3.2.3 📁 FOLDER — File Explorer
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.3.1 | `set_root(path)` | Đặt thư mục gốc |
| 3.2.3.2 | `_build_tree_view()` | Xây dựng cây thư mục (QFileSystemModel) |
| 3.2.3.3 | `_open_file(path)` | Mở file được chọn |
| 3.2.3.4 | `_view_source()` | Xem source code (syntax highlighting) |
| 3.2.3.5 | `_view_html_preview()` | Preview HTML (WebEngine/rich text) |
| 3.2.3.6 | `_view_office_doc()` | Xem Office doc (docx/pdf/xlsx/…) |
| 3.2.3.7 | `_view_image()` | Hiển thị ảnh inline |
| 3.2.3.8 | `_edit_file()` | Chỉnh sửa file (code editor) |
| 3.2.3.9 | `_save_file()` | Lưu file |
| 3.2.3.10 | `_preview_toggle()` | Chuyển đổi Preview ⇄ Edit |
| 3.2.3.11 | `_create_new_file()` | Tạo file mới |
| 3.2.3.12 | `_create_new_folder()` | Tạo folder mới |
| 3.2.3.13 | `_rename_item()` | Đổi tên file/folder |
| 3.2.3.14 | `_delete_item()` | Xóa file/folder |
| 3.2.3.15 | `_copy_item()` | Sao chép file/folder |
| 3.2.3.16 | `_paste_item()` | Dán file/folder |
| 3.2.3.17 | `refresh_ai_models()` | Làm mới danh sách AI models cho AI Edit |
**AI Edit (Chỉnh Sửa File Bằng AI):**
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.3.18 | `_ai_send()` | Gửi yêu cầu AI edit |
| 3.2.3.19 | `_ai_apply()` | Áp dụng thay đổi AI |
| 3.2.3.20 | `_ai_discard()` | Hủy thay đổi AI |
| 3.2.3.21 | `_reset_ai_conversation()` | Xóa hội thoại AI edit |
| 3.2.3.22 | `_ai_apply_routing()` | Áp dụng routing cho AI edit |
#### 3.2.4 🧠 GRAPH RAG — Knowledge Graph
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.4.1 | `_build_graph()` | Xây dựng knowledge graph từ code/documents |
| 3.2.4.2 | `_render_d3_graph()` | Render graph bằng D3.js (WebEngine) |
| 3.2.4.3 | `_render_native_graph()` | Render graph bằng QGraphicsView (fallback) |
| 3.2.4.4 | `_auto_rotate()` | Tự xoay graph khi idle |
| 3.2.4.5 | `_on_node_click()` | Xử lý click node (mở folder) |
| 3.2.4.6 | `_open_node_path()` | Mở folder chứa node |
| 3.2.4.7 | `_refresh_graph()` | Tự cập nhật graph khi có output mới |
| 3.2.4.8 | `_search_graph()` | Tìm kiếm trong graph |
| 3.2.4.9 | `_filter_by_kind()` | Lọc node theo loại |
| 3.2.4.10 | `_zoom_graph()` | Zoom graph |
**Graph-RAG Q&A:**
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.4.11 | `_ask_question()` | Hỏi AI về graph |
| 3.2.4.12 | `_on_ask_event()` | Xử lý streaming answer |
| 3.2.4.13 | `_on_ask_done()` | Khi AI trả lời xong |
| 3.2.4.14 | `_candidate_file_paths()` | Lấy danh sách file để extract |
| 3.2.4.15 | `_extract_tmp_dir()` | Tạo thư mục tạm cho extraction |
| 3.2.4.16 | `_clear_extracts()` | Xóa dữ liệu extract tạm |
---
## 🔹 4. 📊 MONITORING (Giám Sát)
### 4.1 Overview — Tổng Quan
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 4.1.1 | `_refresh_overview()` | Làm mới tất cả cards overview |
| 4.1.2 | `_refresh_usage_cards()` | Token Usage & Cost cards (Total, Input, Output, Cache) |
| 4.1.3 | `_refresh_resource_usage()` | Resource usage (CPU, RAM, Disk) |
| 4.1.4 | `_refresh_recent_activity()` | Hoạt động gần đây |
| 4.1.5 | `_refresh_sandbox_details()` | Chi tiết sandbox (PID, uptime, limits) |
| 4.1.6 | `_refresh_permissions()` | Hiển thị permissions hiện tại |
| 4.1.7 | `_refresh_audit_log()` | Audit log gần đây |
| 4.1.8 | `_refresh_budget()` | Budget card (còn lại / đã dùng) |
| 4.1.9 | `_apply_budget()` | Lưu budget mới |
### 4.2 Security Events — Sự Kiện Bảo Mật
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 4.2.1 | `_refresh_security_events()` | Làm mới bảng security events (audit log `kind="security_block"`) |
| 4.2.2 | `_filter_security_events()` | Lọc sự kiện bảo mật |
| 4.2.3 | `_sort_events()` | Sắp xếp bảng events |
### 4.3 MCP Call History — Lịch Sử Gọi MCP
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 4.3.1 | `_refresh_mcp_calls()` | Làm mới bảng MCP calls (audit log `kind="mcp_call"`) |
| 4.3.2 | `_filter_mcp_calls()` | Lọc MCP calls |
### 4.4 Action Logs — Nhật Ký Hành Động
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 4.4.1 | `_refresh_action_logs()` | Làm mới bảng action logs (toàn bộ audit log) |
| 4.4.2 | `_filter_action_logs()` | Lọc action logs |
| 4.4.3 | `_sort_action_logs()` | Sắp xếp action logs |
### 4.5 Agent Status — Trạng Thái Agent
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 4.5.1 | `_refresh_agent_status()` | Làm mới trạng thái các agent (Cowork, Co4E, Schedule, GraphRAG) |
### 4.6 Security Settings — Cài Đặt Bảo Mật
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 4.6.1 | `_toggle_sandbox()` | Bật/tắt sandbox |
| 4.6.2 | `_toggle_network_block()` | Chặn kết nối mạng |
| 4.6.3 | `_set_resource_limits()` | Đặt giới hạn tài nguyên (CPU/RAM/Disk) |
| 4.6.4 | `_toggle_command_confirm()` | Xác nhận trước khi chạy lệnh |
| 4.6.5 | `_manage_permissions()` | Quản lý quyền truy cập |
---
## 🔹 5. ⚙️ SETTINGS (Cài Đặt)
### 5.1 AI Provider — Nhà Cung Cấp AI
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 5.1.1 | `_on_provider_changed()` | Khi thay đổi provider |
| 5.1.2 | `_load_models(provider)` | Load danh sách models của provider |
| 5.1.3 | `_test_connection(provider)` | Kiểm tra kết nối provider |
| 5.1.4 | `_stash_provider_fields()` | Lưu tạm các trường cấu hình provider |
| 5.1.5 | `_apply_provider_fields()` | Áp dụng các trường cấu hình provider |
| 5.1.6 | Model List Widget | Hiển thị danh sách models (enable/disable, chọn default) |
### 5.2 Connectors (MCP) — Kết Nối
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 5.2.1 | `_add_mcp_server()` | Thêm MCP server mới |
| 5.2.2 | `_edit_mcp_server()` | Sửa MCP server |
| 5.2.3 | `_delete_mcp_server()` | Xóa MCP server |
| 5.2.4 | `_test_mcp_connection()` | Kiểm tra kết nối MCP |
| 5.2.5 | MS365 Connector | Kết nối Microsoft 365 (tự động khi đăng nhập) |
| 5.2.6 | CAD/CAE Connectors | Kết nối CAD/CAE tools |
### 5.3 Parameters — Tham Số
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 5.3.1 | `attach_tokens` | Giới hạn token cho attachments |
| 5.3.2 | `attach_files` | Giới hạn số file attachments |
| 5.3.3 | `struct_nodes` | Giới hạn nodes cho GraphRAG |
| 5.3.4 | `struct_edges` | Giới hạn edges cho GraphRAG |
### 5.4 Model Routing — Định Tuyến Model
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 5.4.1 | `routing_mode` | Chế độ routing (Off/Auto/Manual) |
| 5.4.2 | `routing_policy` | Chính sách routing |
| 5.4.3 | `routing_min_gain` | Threshold tối thiểu để chuyển model |
| 5.4.4 | `routing_timeout` | Timeout xác nhận routing |
| 5.4.5 | `routing_interval` | Khoảng thời gian đánh giá lại |
| 5.4.6 | `routing_concurrency` | Số lượng request đồng thời per provider |
| 5.4.7 | `routing_judge` | Model dùng để đánh giá routing |
### 5.5 General — Chung
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 5.5.1 | Language Picker | Chọn ngôn ngữ (EN/VI/JP) |
| 5.5.2 | `tray_chk` | Minimize to tray thay vì đóng |
| 5.5.3 | `notify_chk` | Thông báo khi task hoàn thành |
| 5.5.4 | `_save()` | Lưu tất cả cài đặt |
---
## 🔹 6. 📜 HISTORY SIDEBAR (Thanh Lịch Sử Bên Trái)
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 6.0.1 | `refresh()` | Làm mới danh sách hội thoại |
| 6.0.2 | `set_view_state(session_id, running_ids)` | Đánh dấu hội thoại hiện tại + đang chạy |
| 6.0.3 | `set_project_filter(project_id)` | Lọc theo dự án |
| 6.0.4 | `_open_chat()` | Mở hội thoại khi click |
| 6.0.5 | `_context_menu()` | Menu chuột phải (Pin/Unpin, Rename, Delete) |
| 6.0.6 | `_bulk_delete_menu()` | Menu xóa hàng loạt |
| 6.0.7 | `_confirm_and_delete_selected()` | Xác nhận và xóa các hội thoại đã chọn |
| 6.0.8 | `new_chat(kind)` | Tạo hội thoại mới |
| 6.0.9 | `collapse_requested()` | Thu nhỏ sidebar |
| 6.0.10 | `expand_requested()` | Mở rộng sidebar |
---
## 🔹 7. 🧩 CÁC CHỨC NĂNG TOÀN CẦU (Global)
### 7.1 MainWindow (app.py)
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 7.1.1 | `_build_topbar()` | Xây dựng thanh trên cùng (User name, Settings, Language) |
| 7.1.2 | `_build_nav_rail()` | Xây dựng thanh điều hướng bên trái |
| 7.1.3 | `_toggle_nav()` | Thu/mở nav rail (icon-only ↔ full) |
| 7.1.4 | `_apply_nav_labels()` | Áp dụng labels cho nav items |
| 7.1.5 | `_ensure_page(row)` | Xây dựng page lười (lazy loading) |
| 7.1.6 | `_refresh_history()` | Làm mới history của tất cả panes |
| 7.1.7 | `_on_scheduled_task_done()` | Thông báo khi scheduled task hoàn thành |
| 7.1.8 | `_notify_task()` | Thông báo khi task hoàn thành |
| 7.1.9 | `_on_projects_changed()` | Khi danh sách dự án thay đổi |
| 7.1.10 | `_on_pane_turn_finished()` | Khi turn trong pane hoàn thành |
| 7.1.11 | `_open_settings()` | Mở dialog cài đặt |
| 7.1.12 | `_fit_to_screen()` | Tự động fit cửa sổ theo màn hình |
| 7.1.13 | Toast notifications | Hiển thị thông báo toast |
| 7.1.14 | System Tray | Minimize to tray, tray notifications |
### 7.2 Skills Manager
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 7.2.1 | `_open_skills_manager()` | Mở Skill Manager |
| 7.2.2 | `seed_library_skills()` | Gieo skills mặc định |
| 7.2.3 | `prune_seeded_builtins()` | Dọn dẹp skills built-in |
### 7.3 Welcome Dialog
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 7.3.1 | `maybe_show_welcome()` | Hiển thị dialog chào mừng lần đầu |
### 7.4 i18n (Đa Ngôn Ngữ)
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 7.4.1 | `tr(key)` | Dịch chuỗi theo ngôn ngữ hiện tại |
| 7.4.2 | `set_language(lang)` | Đặt ngôn ngữ |
| 7.4.3 | `get_language()` | Lấy ngôn ngữ hiện tại |
| 7.4.4 | `on_language_changed(callback)` | Đăng ký callback khi ngôn ngữ thay đổi |
### 7.5 Task Scheduler (Nền)
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 7.5.1 | `task_finished` signal | Khi scheduled task hoàn thành |
| 7.5.2 | `history_ready` signal | Khi session của task sẵn sàng |
| 7.5.3 | `running_session_ids()` | Lấy danh sách session đang chạy |
---
## 📌 TỔNG KẾT
| Navigation Item | Số Hàm/Chức Năng |
|----------------|:-:|
| 📊 Dashboard | ~10 |
| 📅 Schedule Task | ~25 |
| 🏠 Workspace → Projects | ~8 |
| 🏠 Workspace → Cowork | ~34 |
| 🏠 Workspace → Co4E | ~36 |
| 🏠 Workspace → Folder | ~22 |
| 🏠 Workspace → Graph RAG | ~16 |
| 📊 Monitoring | ~20 |
| ⚙️ Settings | ~25 |
| 📜 History Sidebar | ~10 |
| 🌐 Global Functions | ~15 |
| **TỔNG CỘNG** | **~221** |
> **Lưu ý:** Đây là danh sách các hàm/chức năng ở cấp UI và business logic chính. Các hàm core (providers, MCP, worker, security…) nằm ở tầng dưới và được gọi bởi các hàm UI ở trên.
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+542
View File
@@ -0,0 +1,542 @@
[
{
"slug": "dashboard",
"title": "Dashboard",
"theme": "dark",
"note": "ui/dashboard_tab.py:35",
"file": "screens/dashboard-dark.png",
"error": "",
"nav": "Dashboard",
"nav_expected": "Dashboard"
},
{
"slug": "schedule-kanban",
"title": "Schedule Task — Kanban",
"theme": "dark",
"note": "ui/schedule_task_tab.py:70",
"file": "screens/schedule-kanban-dark.png",
"error": "",
"nav": "Schedule Task",
"nav_expected": "Schedule Task"
},
{
"slug": "schedule-calendar",
"title": "Schedule Task — Calendar",
"theme": "dark",
"note": "ui/calendar_view.py:88",
"file": "screens/schedule-calendar-dark.png",
"error": "",
"nav": "Schedule Task",
"nav_expected": "Schedule Task"
},
{
"slug": "workspace-project",
"title": "Workspace ▸ Project",
"theme": "dark",
"note": "ui/workspace_tab.py:188",
"file": "screens/workspace-project-dark.png",
"error": "",
"nav": "Workspace",
"nav_expected": "Workspace"
},
{
"slug": "workspace-cowork",
"title": "Workspace ▸ Cowork",
"theme": "dark",
"note": "ui/cowork_tab.py:21",
"file": "screens/workspace-cowork-dark.png",
"error": "",
"nav": "Cowork",
"nav_expected": "Cowork"
},
{
"slug": "workspace-co4e",
"title": "Workspace ▸ Co4E",
"theme": "dark",
"note": "ui/co4e_tab.py:228",
"file": "screens/workspace-co4e-dark.png",
"error": "",
"nav": "Co4E",
"nav_expected": "Co4E"
},
{
"slug": "workspace-folder",
"title": "Workspace ▸ Folder",
"theme": "dark",
"note": "ui/folder_tab.py:238",
"file": "screens/workspace-folder-dark.png",
"error": "",
"nav": "Thư mục",
"nav_expected": "Thư mục"
},
{
"slug": "workspace-graphrag",
"title": "Workspace ▸ GraphRAG",
"theme": "dark",
"note": "ui/structure_graph_view.py:188",
"file": "screens/workspace-graphrag-dark.png",
"error": "",
"nav": "GraphRAG",
"nav_expected": "GraphRAG"
},
{
"slug": "monitoring-tổng-quan",
"title": "Monitoring ▸ Tổng quan",
"theme": "dark",
"note": "ui/monitoring_tab.py:132",
"file": "screens/monitoring-tổng-quan-dark.png",
"error": "",
"nav": "Tổng quan",
"nav_expected": "Tổng quan"
},
{
"slug": "monitoring-sự-kiện-bảo-mật",
"title": "Monitoring ▸ Sự kiện bảo mật",
"theme": "dark",
"note": "ui/monitoring_tab.py:132",
"file": "screens/monitoring-sự-kiện-bảo-mật-dark.png",
"error": "",
"nav": "Sự kiện bảo mật",
"nav_expected": "Sự kiện bảo mật"
},
{
"slug": "monitoring-lịch-sử-gọi-mcp",
"title": "Monitoring ▸ Lịch sử gọi MCP",
"theme": "dark",
"note": "ui/monitoring_tab.py:132",
"file": "screens/monitoring-lịch-sử-gọi-mcp-dark.png",
"error": "",
"nav": "Lịch sử gọi MCP",
"nav_expected": "Lịch sử gọi MCP"
},
{
"slug": "monitoring-nhật-ký-hành-động",
"title": "Monitoring ▸ Nhật ký hành động",
"theme": "dark",
"note": "ui/monitoring_tab.py:132",
"file": "screens/monitoring-nhật-ký-hành-động-dark.png",
"error": "",
"nav": "Nhật ký hành động",
"nav_expected": "Nhật ký hành động"
},
{
"slug": "monitoring-trạng-thái-agent",
"title": "Monitoring ▸ Trạng thái Agent",
"theme": "dark",
"note": "ui/monitoring_tab.py:132",
"file": "screens/monitoring-trạng-thái-agent-dark.png",
"error": "",
"nav": "Trạng thái Agent",
"nav_expected": "Trạng thái Agent"
},
{
"slug": "monitoring-agents-admin",
"title": "Monitoring ▸ Agents Admin",
"theme": "dark",
"note": "ui/monitoring_tab.py:132",
"file": "screens/monitoring-agents-admin-dark.png",
"error": "",
"nav": "Agents Admin",
"nav_expected": "Agents Admin"
},
{
"slug": "monitoring-công-cụ",
"title": "Monitoring ▸ Công cụ",
"theme": "dark",
"note": "ui/monitoring_tab.py:132",
"file": "screens/monitoring-công-cụ-dark.png",
"error": "",
"nav": "Công cụ",
"nav_expected": "Công cụ"
},
{
"slug": "monitoring-icon",
"title": "Monitoring ▸ Icon",
"theme": "dark",
"note": "ui/monitoring_tab.py:132",
"file": "screens/monitoring-icon-dark.png",
"error": "",
"nav": "Icon",
"nav_expected": "Icon"
},
{
"slug": "dialog-settings",
"title": "Settings",
"theme": "dark",
"note": "ui/settings_dialog.py:26",
"file": "screens/dialog-settings-dark.png",
"error": "",
"nav": "",
"nav_expected": ""
},
{
"slug": "dialog-task-editor",
"title": "Task Editor",
"theme": "dark",
"note": "ui/task_editor_dialog.py:55",
"file": "screens/dialog-task-editor-dark.png",
"error": "",
"nav": "",
"nav_expected": ""
},
{
"slug": "dialog-skills",
"title": "Skills manager",
"theme": "dark",
"note": "ui/skills_dialog.py:108",
"file": "screens/dialog-skills-dark.png",
"error": "",
"nav": "",
"nav_expected": ""
},
{
"slug": "dialog-skill-edit",
"title": "Skill editor",
"theme": "dark",
"note": "ui/skills_dialog.py:23",
"file": "screens/dialog-skill-edit-dark.png",
"error": "",
"nav": "",
"nav_expected": ""
},
{
"slug": "dialog-file-edit",
"title": "File view & AI edit",
"theme": "dark",
"note": "ui/file_edit_dialog.py:50",
"file": "screens/dialog-file-edit-dark.png",
"error": "",
"nav": "",
"nav_expected": ""
},
{
"slug": "dialog-co4e-agent",
"title": "Co4E agent editor",
"theme": "dark",
"note": "ui/co4e_agent_dialog.py:23",
"file": "screens/dialog-co4e-agent-dark.png",
"error": "",
"nav": "",
"nav_expected": ""
},
{
"slug": "dialog-ext-connector",
"title": "External connector",
"theme": "dark",
"note": "ui/ext_connector_dialog.py:23",
"file": "screens/dialog-ext-connector-dark.png",
"error": "",
"nav": "",
"nav_expected": ""
},
{
"slug": "dialog-permission",
"title": "Permission request",
"theme": "dark",
"note": "ui/permission_dialog.py:13",
"file": "screens/dialog-permission-dark.png",
"error": "",
"nav": "",
"nav_expected": ""
},
{
"slug": "dialog-agent-edit",
"title": "Admin agent editor",
"theme": "dark",
"note": "ui/agents_admin_tab.py:35",
"file": "screens/dialog-agent-edit-dark.png",
"error": "",
"nav": "",
"nav_expected": ""
},
{
"slug": "dialog-login",
"title": "Login (dead screen — not wired)",
"theme": "dark",
"note": "ui/login_dialog.py:57",
"file": "screens/dialog-login-dark.png",
"error": "",
"nav": "",
"nav_expected": ""
},
{
"slug": "overlay-help-panel",
"title": "Help dock — expanded panel",
"theme": "dark",
"note": "ui/help_agent_widget.py:79",
"file": "screens/overlay-help-panel-dark.png",
"error": "",
"nav": "",
"nav_expected": ""
},
{
"slug": "dashboard",
"title": "Dashboard",
"theme": "light",
"note": "ui/dashboard_tab.py:35",
"file": "screens/dashboard-light.png",
"error": "",
"nav": "Dashboard",
"nav_expected": "Dashboard"
},
{
"slug": "schedule-kanban",
"title": "Schedule Task — Kanban",
"theme": "light",
"note": "ui/schedule_task_tab.py:70",
"file": "screens/schedule-kanban-light.png",
"error": "",
"nav": "Schedule Task",
"nav_expected": "Schedule Task"
},
{
"slug": "schedule-calendar",
"title": "Schedule Task — Calendar",
"theme": "light",
"note": "ui/calendar_view.py:88",
"file": "screens/schedule-calendar-light.png",
"error": "",
"nav": "Schedule Task",
"nav_expected": "Schedule Task"
},
{
"slug": "workspace-project",
"title": "Workspace ▸ Project",
"theme": "light",
"note": "ui/workspace_tab.py:188",
"file": "screens/workspace-project-light.png",
"error": "",
"nav": "Workspace",
"nav_expected": "Workspace"
},
{
"slug": "workspace-cowork",
"title": "Workspace ▸ Cowork",
"theme": "light",
"note": "ui/cowork_tab.py:21",
"file": "screens/workspace-cowork-light.png",
"error": "",
"nav": "Cowork",
"nav_expected": "Cowork"
},
{
"slug": "workspace-co4e",
"title": "Workspace ▸ Co4E",
"theme": "light",
"note": "ui/co4e_tab.py:228",
"file": "screens/workspace-co4e-light.png",
"error": "",
"nav": "Co4E",
"nav_expected": "Co4E"
},
{
"slug": "workspace-folder",
"title": "Workspace ▸ Folder",
"theme": "light",
"note": "ui/folder_tab.py:238",
"file": "screens/workspace-folder-light.png",
"error": "",
"nav": "Thư mục",
"nav_expected": "Thư mục"
},
{
"slug": "workspace-graphrag",
"title": "Workspace ▸ GraphRAG",
"theme": "light",
"note": "ui/structure_graph_view.py:188",
"file": "screens/workspace-graphrag-light.png",
"error": "",
"nav": "GraphRAG",
"nav_expected": "GraphRAG"
},
{
"slug": "monitoring-tổng-quan",
"title": "Monitoring ▸ Tổng quan",
"theme": "light",
"note": "ui/monitoring_tab.py:132",
"file": "screens/monitoring-tổng-quan-light.png",
"error": "",
"nav": "Tổng quan",
"nav_expected": "Tổng quan"
},
{
"slug": "monitoring-sự-kiện-bảo-mật",
"title": "Monitoring ▸ Sự kiện bảo mật",
"theme": "light",
"note": "ui/monitoring_tab.py:132",
"file": "screens/monitoring-sự-kiện-bảo-mật-light.png",
"error": "",
"nav": "Sự kiện bảo mật",
"nav_expected": "Sự kiện bảo mật"
},
{
"slug": "monitoring-lịch-sử-gọi-mcp",
"title": "Monitoring ▸ Lịch sử gọi MCP",
"theme": "light",
"note": "ui/monitoring_tab.py:132",
"file": "screens/monitoring-lịch-sử-gọi-mcp-light.png",
"error": "",
"nav": "Lịch sử gọi MCP",
"nav_expected": "Lịch sử gọi MCP"
},
{
"slug": "monitoring-nhật-ký-hành-động",
"title": "Monitoring ▸ Nhật ký hành động",
"theme": "light",
"note": "ui/monitoring_tab.py:132",
"file": "screens/monitoring-nhật-ký-hành-động-light.png",
"error": "",
"nav": "Nhật ký hành động",
"nav_expected": "Nhật ký hành động"
},
{
"slug": "monitoring-trạng-thái-agent",
"title": "Monitoring ▸ Trạng thái Agent",
"theme": "light",
"note": "ui/monitoring_tab.py:132",
"file": "screens/monitoring-trạng-thái-agent-light.png",
"error": "",
"nav": "Trạng thái Agent",
"nav_expected": "Trạng thái Agent"
},
{
"slug": "monitoring-agents-admin",
"title": "Monitoring ▸ Agents Admin",
"theme": "light",
"note": "ui/monitoring_tab.py:132",
"file": "screens/monitoring-agents-admin-light.png",
"error": "",
"nav": "Agents Admin",
"nav_expected": "Agents Admin"
},
{
"slug": "monitoring-công-cụ",
"title": "Monitoring ▸ Công cụ",
"theme": "light",
"note": "ui/monitoring_tab.py:132",
"file": "screens/monitoring-công-cụ-light.png",
"error": "",
"nav": "Công cụ",
"nav_expected": "Công cụ"
},
{
"slug": "monitoring-icon",
"title": "Monitoring ▸ Icon",
"theme": "light",
"note": "ui/monitoring_tab.py:132",
"file": "screens/monitoring-icon-light.png",
"error": "",
"nav": "Icon",
"nav_expected": "Icon"
},
{
"slug": "dialog-settings",
"title": "Settings",
"theme": "light",
"note": "ui/settings_dialog.py:26",
"file": "screens/dialog-settings-light.png",
"error": "",
"nav": "",
"nav_expected": ""
},
{
"slug": "dialog-task-editor",
"title": "Task Editor",
"theme": "light",
"note": "ui/task_editor_dialog.py:55",
"file": "screens/dialog-task-editor-light.png",
"error": "",
"nav": "",
"nav_expected": ""
},
{
"slug": "dialog-skills",
"title": "Skills manager",
"theme": "light",
"note": "ui/skills_dialog.py:108",
"file": "screens/dialog-skills-light.png",
"error": "",
"nav": "",
"nav_expected": ""
},
{
"slug": "dialog-skill-edit",
"title": "Skill editor",
"theme": "light",
"note": "ui/skills_dialog.py:23",
"file": "screens/dialog-skill-edit-light.png",
"error": "",
"nav": "",
"nav_expected": ""
},
{
"slug": "dialog-file-edit",
"title": "File view & AI edit",
"theme": "light",
"note": "ui/file_edit_dialog.py:50",
"file": "screens/dialog-file-edit-light.png",
"error": "",
"nav": "",
"nav_expected": ""
},
{
"slug": "dialog-co4e-agent",
"title": "Co4E agent editor",
"theme": "light",
"note": "ui/co4e_agent_dialog.py:23",
"file": "screens/dialog-co4e-agent-light.png",
"error": "",
"nav": "",
"nav_expected": ""
},
{
"slug": "dialog-ext-connector",
"title": "External connector",
"theme": "light",
"note": "ui/ext_connector_dialog.py:23",
"file": "screens/dialog-ext-connector-light.png",
"error": "",
"nav": "",
"nav_expected": ""
},
{
"slug": "dialog-permission",
"title": "Permission request",
"theme": "light",
"note": "ui/permission_dialog.py:13",
"file": "screens/dialog-permission-light.png",
"error": "",
"nav": "",
"nav_expected": ""
},
{
"slug": "dialog-agent-edit",
"title": "Admin agent editor",
"theme": "light",
"note": "ui/agents_admin_tab.py:35",
"file": "screens/dialog-agent-edit-light.png",
"error": "",
"nav": "",
"nav_expected": ""
},
{
"slug": "dialog-login",
"title": "Login (dead screen — not wired)",
"theme": "light",
"note": "ui/login_dialog.py:57",
"file": "screens/dialog-login-light.png",
"error": "",
"nav": "",
"nav_expected": ""
},
{
"slug": "overlay-help-panel",
"title": "Help dock — expanded panel",
"theme": "light",
"note": "ui/help_agent_widget.py:79",
"file": "screens/overlay-help-panel-light.png",
"error": "",
"nav": "",
"nav_expected": ""
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+598
View File
File diff suppressed because one or more lines are too long
+23
View File
@@ -0,0 +1,23 @@
services:
cowork-desktop:
image: python:3.11-slim-bookworm
container_name: cowork-local-desktop-preview
working_dir: /workspace
volumes:
- /workspace:/workspace:Z
- pip-cache:/root/.cache/pip:Z
ports:
- "6080:6080"
environment:
PYTHONUNBUFFERED: "1"
QT_QPA_PLATFORM: "vnc:size=1280x800:depth=32"
QT_QPA_VNC_HOST: "127.0.0.1"
QT_QPA_VNC_PORT: "5900"
QSG_RHI_BACKEND: "software"
PYTHONPATH: "/opt"
entrypoint: ["/bin/sh", "/workspace/.vibeflow-preview/entrypoint.sh"]
command: []
restart: unless-stopped
volumes:
pip-cache: {}
View File
+9
View File
@@ -0,0 +1,9 @@
PySide6>=6.6
pydantic>=2
requests
psutil
pygments
openpyxl
python-pptx
networkx
pytest
+24
View File
@@ -0,0 +1,24 @@
# Cowork-Local BamBOO — dependencies
# Install: pip install -r requirements.txt
# --- Core UI framework ---
PySide6>=6.6.0
# --- HTTP client ---
requests>=2.31.0
# --- Process & resource monitoring ---
psutil>=5.9.0
# --- Document handling ---
openpyxl>=3.1.0 # Excel (.xlsx) creation & reading
python-pptx>=0.6.0 # PowerPoint (.pptx) editing
pypdf>=4.0.0 # PDF text extraction (preferred)
# PyPDF2>=3.0.0 # PDF fallback (optional, pypdf preferred)
# --- Microsoft 365 integration ---
msal>=1.24.0 # OAuth device-code flow for MS365
keyring>=24.0.0 # OS credential store (token cache)
# --- MCP (Model Context Protocol) ---
mcp>=1.0.0 # MCP client SDK (stdio transport)
+677
View File
@@ -0,0 +1,677 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Cowork-Local BamBOO</title>
<style>
:root{
--bg:#f8faff;--surface:#fff;--surface-2:rgba(255,255,255,0.95);
--text:#1a202c;--muted:#6b7280;
--accent:#003087;--accent2:#0072CE;--accent3:#FF6B00;
--border:#e2e8f0;--border-strong:#cbd5e1;
--gradient1:linear-gradient(135deg,#003087 0%,#0072CE 100%);
--gradient-hero:linear-gradient(160deg,#001a4d 0%,#003087 45%,#0072CE 100%);
--shadow:0 2px 12px rgba(0,0,0,.07);
}
[data-theme="dark"]{
--bg:#0a0e1a;--surface:#111827;--surface-2:rgba(17,24,39,0.92);
--text:#e2e8f0;--muted:#9ca3af;
--border:#374151;--border-strong:#4b5563;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{
font-family:"Meiryo UI","Yu Gothic","Meiryo","Hiragino Sans","Noto Sans CJK JP",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
background:var(--bg);color:var(--text);
font-size:20px;line-height:1.55;
transition:background .3s,color .3s;
}
.deck{display:flex;flex-direction:column;align-items:center}
/* Scaled wrapper so .slide (1920×1080) fits viewport */
.slide-wrap{width:var(--scaled-w,1920px);height:var(--scaled-h,1080px);position:relative;overflow:hidden;border-bottom:1px solid var(--border)}
.slide-wrap > .slide{transform:scale(var(--scale,1));transform-origin:top left;border-bottom:0}
.slide{
position:relative;
width:1920px;height:1080px;min-height:1080px;max-height:1080px;
padding:72px 96px 92px;
display:flex;flex-direction:column;
background:var(--bg);overflow:hidden;
}
.slide.cover{align-items:center;justify-content:center;text-align:center;background:var(--gradient-hero);color:white;padding:96px}
.slide.section-divider{align-items:center;justify-content:center;text-align:center;background:var(--gradient1);color:white}
h1,h2,h3{font-weight:700;line-height:1.2}
.slide h1{font-size:3.2em;margin-bottom:.3em}
.slide h2{font-size:2.2em;color:var(--accent);margin-bottom:.4em}
.slide h3{font-size:1.2em;color:var(--accent2);margin-bottom:.3em}
.slide.section-divider h2{color:white!important;font-size:2.8em}
.slide.section-divider p{color:rgba(255,255,255,.88);font-size:1.2em;margin-top:.6em}
.slide.cover h1{color:white;font-size:3.4em}
.slide p{font-size:1.1em;color:var(--muted);margin-bottom:1rem}
.slide ul{padding-left:1.4em;margin-top:.4rem}
.slide ul li{font-size:clamp(1.1em,1.5vw,1.3em);margin:.5em 0;line-height:1.45}
.card-grid{display:grid;gap:1.2rem;margin-top:.6rem}
.grid2{grid-template-columns:1fr 1fr}
.grid3{grid-template-columns:1fr 1fr 1fr}
.grid4{grid-template-columns:1fr 1fr 1fr 1fr}
.card{background:var(--surface-2);border:1px solid var(--border);border-radius:16px;padding:1.5rem 1.8rem;box-shadow:var(--shadow);overflow-wrap:break-word;word-break:break-word}
.card-title{font-weight:700;font-size:1.35em;color:var(--accent);margin-bottom:.5em}
.badge{display:inline-block;padding:6px 16px;border-radius:20px;font-size:.9em;font-weight:600;margin-bottom:.6rem}
.badge-blue{background:#dbeafe;color:#1e40af}
.badge-orange{background:#fed7aa;color:#c2410c}
.badge-green{background:#d1fae5;color:#065f46}
.badge-purple{background:#ede9fe;color:#5b21b6}
.badge-red{background:#fee2e2;color:#991b1b}
.flow-row{display:flex;align-items:center;justify-content:center;gap:1rem;flex-wrap:wrap;margin:.6em 0}
.flow-node{background:var(--surface-2);border:2px solid var(--accent2);border-radius:12px;padding:.8rem 1.4rem;text-align:center;font-size:clamp(.9em,1.3vw,1.1em);font-weight:600;color:var(--text);min-width:140px;box-shadow:var(--shadow)}
.flow-node.accent{background:var(--gradient1);color:white;border-color:var(--accent)}
.flow-node.warm{background:linear-gradient(135deg,#FF6B00,#FF8F3A);color:white;border-color:#FF6B00}
.flow-node.orange{background:linear-gradient(135deg,#ff6b00,#ff9500);color:white;border-color:#ff6b00}
.flow-node.green{background:#065f46;color:white;border-color:#065f46}
.flow-arrow{font-size:1.6em;color:var(--accent2);font-weight:700}
.flow-label{font-size:1em;color:var(--muted);text-align:center;margin:.3em 0;font-weight:600}
.arrow{font-size:1.3em;color:var(--accent2);font-weight:700}
.gantt-wrap{overflow-x:auto;width:100%;margin-top:.6rem;display:grid;grid-template-columns:max-content 1fr;column-gap:.6rem;row-gap:.22rem;align-items:center;font-size:clamp(.85em,1.2vw,1em)}
.gantt-row{display:contents}
.gantt-label{min-width:0;width:auto;color:var(--text);font-weight:500}
.gantt-track{background:var(--border);border-radius:4px;height:22px;position:relative;min-width:240px}
.gantt-bar{position:absolute;height:100%;border-radius:4px}
.gantt-row.gantt-sep > .gantt-label,.gantt-row.gantt-sep > .gantt-track{margin-top:.4rem}
.highlight-box{background:linear-gradient(135deg,#e5eaf4,#e5f1fa);border-left:4px solid var(--accent2);border-radius:10px;padding:1.3rem 1.8rem;margin-top:1.5rem;font-size:1.1em}
.accent-box{background:linear-gradient(135deg,#ffe2cc,#fff4ea);border-left:4px solid var(--accent3);border-radius:10px;padding:1.3rem 1.8rem;margin-top:1rem;font-size:1.1em}
table{width:100%;border-collapse:collapse;font-size:clamp(.9em,1.3vw,1.05em);margin-top:.6rem}
th{background:var(--gradient1);color:white;padding:12px 16px;text-align:left;font-weight:600}
td{padding:10px 16px;border-bottom:1px solid var(--border)}
tr:nth-child(even) td{background:#f1f4fa}
.big-number{font-size:clamp(3em,6vw,5em);font-weight:900;color:var(--accent2);line-height:1}
.big-label{font-size:1.1em;color:var(--muted);margin-top:.3em}
/* ── Sparse-slide modifiers (2026-05-03) ───────────────────────────────
* The base font-sizes assume a content-dense slide (close to the per-slide
* cap). Slides with little content (1 short table, 1 small flow-row,
* ≤6 bullets, etc.) end up using only ~50% of the 1920×1080 canvas with
* tiny text. These two modifier classes bump the in-slide typography so
* the visible content fills more of the frame. The min font-sizes stay
* intact for content-dense slides — only sparse slides opt in.
*
* Heuristic for the agent (see "Slide Density Sizing" in the prompt):
* slide--sparse — ~1.3× bump : ≤8 short bullets, OR 1 small table
* (≤6 rows), OR 1 flow-row + ≤6 bullets, OR ≤2
* cards in card-grid.
* slide--very-sparse — ~1.5× bump : ≤4 short bullets, OR a single
* small table (≤4 rows), OR 1 flow-row only, OR
* a single highlight-box/accent-box, OR a single
* big-number metric.
* Header/footer chrome (slide-number, attribution, badge) intentionally
* unchanged so the visual baseline of the deck stays consistent.
*/
.slide.slide--sparse h2{font-size:2.6em}
.slide.slide--sparse h3{font-size:1.5em}
.slide.slide--sparse p{font-size:1.35em}
.slide.slide--sparse ul li{font-size:1.5em;margin:.7em 0}
.slide.slide--sparse table{font-size:1.2em}
.slide.slide--sparse th,.slide.slide--sparse td{padding:14px 18px}
.slide.slide--sparse .flow-node{font-size:1.3em;padding:1rem 1.6rem;min-width:170px}
.slide.slide--sparse .flow-arrow{font-size:1.9em}
.slide.slide--sparse .card-title{font-size:1.55em}
.slide.slide--sparse .card{padding:1.7rem 2rem}
.slide.slide--sparse .highlight-box,.slide.slide--sparse .accent-box{font-size:1.3em;padding:1.5rem 2rem}
.slide.slide--sparse .gantt-wrap{font-size:1.15em}
.slide.slide--very-sparse h2{font-size:3em}
.slide.slide--very-sparse h3{font-size:1.7em}
.slide.slide--very-sparse p{font-size:1.55em}
.slide.slide--very-sparse ul li{font-size:1.7em;margin:.85em 0}
.slide.slide--very-sparse table{font-size:1.4em}
.slide.slide--very-sparse th,.slide.slide--very-sparse td{padding:16px 22px}
.slide.slide--very-sparse .flow-node{font-size:1.55em;padding:1.2rem 1.9rem;min-width:200px}
.slide.slide--very-sparse .flow-arrow{font-size:2.2em}
.slide.slide--very-sparse .card-title{font-size:1.8em}
.slide.slide--very-sparse .card{padding:1.9rem 2.3rem}
.slide.slide--very-sparse .highlight-box,.slide.slide--very-sparse .accent-box{font-size:1.55em;padding:1.8rem 2.4rem}
.slide.slide--very-sparse .gantt-wrap{font-size:1.3em}
/* ── Dense-doc modifier (文字多め / Japanese document-style) ──────────────
* The OPPOSITE of slide--sparse. For "read-as-document" decks (提案書・
* 報告書 / RFP where slides double as standalone reading material) you want
* MORE text per slide, not bigger text. This modifier keeps fonts AT the
* floor (never below — critical rule #2 still holds) but tightens
* line-height / margins / gaps and enables 2-column body prose so a slide
* holds full sentences + many bullets without overflowing 1080px. Used by
* "dense-doc" mode ONLY — never auto-applied, and the sparse-bump heuristic
* does NOT run in this mode (see §Slide Density Sizing dense-doc note).
*/
.slide.slide--dense{padding:56px 80px 80px}
.slide.slide--dense .lead{font-size:1.5em;font-weight:700;color:var(--accent);line-height:1.35;margin-bottom:.5em;border-left:6px solid var(--accent2);padding-left:.55em;padding-right:120px}/* padding-right clears the top-right brand-mark (right:32px+96px wide) so the lead's first line never runs under the logo */
.slide.slide--dense h2{font-size:1.9em;margin-bottom:.25em}
.slide.slide--dense h3{font-size:1.15em;margin:.45em 0 .2em}
.slide.slide--dense p{font-size:1.05em;line-height:1.5;margin-bottom:.55rem;color:var(--text)}
.slide.slide--dense ul{margin-top:.2rem}
.slide.slide--dense ul li{font-size:1.1em;margin:.28em 0;line-height:1.45}
.slide.slide--dense .body-2col{column-count:2;column-gap:2.4rem}
.slide.slide--dense .body-2col li{break-inside:avoid}
.slide.slide--dense .card-grid{gap:.8rem;margin-top:.4rem}
.slide.slide--dense .card{padding:1rem 1.2rem;border-radius:12px}
.slide.slide--dense .card-title{font-size:1.15em;margin-bottom:.3em}
.slide.slide--dense table{font-size:.95em;margin-top:.4rem}
.slide.slide--dense th,.slide.slide--dense td{padding:7px 12px}
.slide.slide--dense .highlight-box,.slide.slide--dense .accent-box{padding:.9rem 1.3rem;margin-top:.7rem;font-size:1.05em}
.slide.slide--dense .footnote{font-size:.8em;color:var(--muted);margin-top:.6rem;line-height:1.4}
.attribution{position:absolute;bottom:14px;left:24px;font-size:12px;color:var(--muted);opacity:.6;pointer-events:none}
.slide.cover .attribution,.slide.section-divider .attribution{color:rgba(255,255,255,.55)}
.slide-number{position:absolute;bottom:14px;right:24px;font-size:14px;color:var(--muted);opacity:.7}
.slide.cover .slide-number,.slide.section-divider .slide-number{color:rgba(255,255,255,.7)}
.brand-mark{position:absolute;top:22px;right:32px;width:96px;height:auto;pointer-events:none;z-index:5}
#slide-nav{position:fixed;top:0;left:0;right:0;background:rgba(0,48,135,.97);color:white;padding:10px 20px;display:flex;align-items:center;justify-content:space-between;z-index:1000;gap:1rem;font-size:.95em}
#slide-nav button{background:rgba(255,255,255,.15);color:white;border:1px solid rgba(255,255,255,.3);border-radius:6px;padding:6px 14px;cursor:pointer;font-size:1em}
.theme-toggle{position:fixed;top:64px;right:20px;z-index:999;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:6px 12px;cursor:pointer;font-size:1.1em;box-shadow:var(--shadow)}
body{padding-top:54px}
.thumb-nav{position:fixed;left:0;top:54px;bottom:0;width:220px;background:var(--surface);border-right:1px solid var(--border);overflow-y:auto;padding:12px 10px;z-index:998;display:none}
.thumb-nav.open{display:block}
body.thumbs-open{padding-left:220px}
body.thumbs-open .theme-toggle{left:240px;right:auto}
.thumb{width:200px;height:112px;margin-bottom:10px;border:2px solid var(--border);border-radius:4px;overflow:hidden;cursor:pointer;position:relative;background:#fff}
.thumb:hover{border-color:var(--accent2)}
.thumb.active{border-color:var(--accent3);box-shadow:0 0 0 2px rgba(255,107,0,.3)}
.thumb-inner{width:1920px;height:1080px;transform:scale(0.1042);transform-origin:top left;pointer-events:none}
.thumb-num{position:absolute;top:3px;left:4px;font-size:10px;font-weight:600;background:rgba(0,0,0,.65);color:#fff;padding:1px 5px;border-radius:3px;z-index:2}
.thumb-toggle{position:fixed;top:64px;left:20px;z-index:999;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:6px 12px;cursor:pointer;font-size:1.1em;box-shadow:var(--shadow)}
/* ── Architecture diagram patterns (redraw source Mermaid into these; NEVER
* embed Mermaid/SVG — dom-to-pptx rasterises it → not editable). See
* /usr/local/share/vibeflow/skills/slide-craft/patterns/arch-diagrams.html for ready HTML.
* Shared node colours are used by arch-node / .proj / .tnode. ───────────── */
/* shared coloured box */
.arch-node,.proj,.tnode{border:1px solid var(--border-strong);border-radius:8px;padding:.45rem .6rem;font-weight:600;font-size:.88em;line-height:1.25;background:#fff;color:var(--text)}
.arch-node small,.proj small,.tnode small{display:block;font-weight:500;color:var(--muted);font-size:.82em}
.arch-node.blue,.proj.blue,.tnode.blue{background:#dbeafe;border-color:#93c5fd;color:#1e40af}
.arch-node.green,.proj.green,.tnode.green{background:#d1fae5;border-color:#6ee7b7;color:#065f46}
.arch-node.orange,.proj.orange,.tnode.orange{background:#fed7aa;border-color:#fdba74;color:#c2410c}
.arch-node.purple,.proj.purple,.tnode.purple{background:#ede9fe;border-color:#c4b5fd;color:#5b21b6}
.arch-node.red,.proj.red,.tnode.red{background:#fee2e2;border-color:#fca5a5;color:#991b1b}
.arch-node.cyan,.proj.cyan,.tnode.cyan{background:#cffafe;border-color:#67e8f9;color:#0e7490}
.proj.muted,.tnode.muted{background:#f1f5f9;border-style:dashed;color:var(--muted)}
/* (1) LAYERED — tiers as columns with titles (Mermaid flowchart LR/TB with parallel layers) */
.arch-wrap{display:grid;gap:.5rem;margin-top:.6rem;align-items:start}
.arch-layer{background:var(--surface-2);border:1px solid var(--border);border-radius:10px;padding:.8rem;text-align:center}
.arch-layer-title{font-weight:700;font-size:.95em;color:var(--accent);margin-bottom:.4rem;border-bottom:2px solid var(--accent2);padding-bottom:.3rem}
.arch-layer .arch-node{margin:.25rem 0}
/* (2) GROUPED CLUSTER — bordered groups + nested sub-clusters + ⇄ connectors
* (Mermaid subgraph / nested topology: VPC, multi-cloud) */
.cloud-flow{display:flex;align-items:stretch;justify-content:center;gap:.55rem;margin-top:.7rem}
.cloud-flow .flow-arrow{align-self:center;flex:0 0 auto}
.zone-col{display:flex;flex-direction:column;gap:.55rem}
.arch-group{border:2px solid var(--border-strong);border-radius:12px;padding:.6rem .7rem;background:var(--surface-2);display:flex;flex-direction:column}
.arch-group-title{font-weight:700;font-size:.88em;color:var(--accent);text-align:center;margin-bottom:.45rem;padding-bottom:.3rem;border-bottom:2px solid var(--accent2)}
.arch-group>.arch-node{margin:.2rem 0}
.arch-sub{border:1px dashed var(--border-strong);border-radius:8px;padding:.4rem;margin-top:.4rem}
.arch-sub-title{font-size:.74em;font-weight:700;color:var(--muted);text-align:center;margin-bottom:.3rem}
.ngrid{display:grid;grid-template-columns:1fr 1fr;gap:.4rem}
.ngrid.three{grid-template-columns:1fr 1fr 1fr}
/* (3) HIERARCHY via nested containment — Org wraps Folders wrap Projects
* (Mermaid tree / org-chart). Containment shows parent→child, no fragile lines. */
.org{border:2.5px solid var(--accent);border-radius:16px;margin-top:1rem;padding:0 1.2rem 1.2rem}
.org-title{display:inline-block;transform:translateY(-50%);background:var(--gradient1);color:#fff;font-weight:700;padding:.5rem 1.4rem;border-radius:10px;font-size:1.05em}
.org-title small{font-weight:500;opacity:.85;margin-left:.5rem;color:#fff}
.folder-row{display:flex;gap:1rem;align-items:flex-start;margin-top:-.4rem}
.folder{flex:1;border:2px solid var(--border-strong);border-radius:12px;padding:0 .8rem .9rem;background:var(--surface-2)}
.folder-title{display:inline-block;transform:translateY(-50%);font-weight:700;padding:.35rem 1rem;border-radius:8px;font-size:.92em;background:#dbeafe;color:#1e40af}
.folder .proj{margin:.4rem 0;display:flex;justify-content:space-between;align-items:center}
.folder .proj small{display:inline}
</style>
</head>
<body>
<div id="slide-nav">
<div><strong>Cowork-Local BamBOO</strong></div>
<div><button onclick="prevSlide()">◀</button> <span id="nav-pos">1 / N</span> <button onclick="nextSlide()">▶</button></div>
</div>
<button class="thumb-toggle" onclick="toggleThumbs()" title="Slides">☰</button>
<button class="theme-toggle" onclick="toggleTheme()">🌙</button>
<aside class="thumb-nav" id="thumb-nav"></aside>
<div class="deck">
<section class="slide cover" id="s1">
<svg width="188" height="116" viewBox="0 0 34 21" xmlns="http://www.w3.org/2000/svg" style="display:block;margin:0 auto 1.8rem;filter:drop-shadow(0 2px 8px rgba(0,0,0,.25))">
<path d="M6.68439 3.50089C4.75756 3.50089 3.12259 4.75793 2.55021 6.5013C2.53888 6.54111 2.52471 6.58093 2.51338 6.6179L2.41703 6.99331L0 17.499H6.08934C7.90849 17.499 9.45845 16.3415 10.0478 14.7204L10.2774 13.7193L12.6292 3.49805H6.68439V3.50089Z" fill="#08509F"/>
<path d="M18.1691 0C16.18 0 14.5025 1.34236 13.984 3.17389C13.9443 3.3104 13.9131 3.44976 13.8876 3.59196L9.88379 21H15.8286C17.866 21 19.5746 19.5951 20.0506 17.6981H20.0535L24.1196 0H18.1691Z" fill="#F27123"/>
<path d="M28.0555 3.50098C26.1967 3.50098 24.6099 4.6727 23.9865 6.31937C23.9553 6.40469 23.8448 6.75165 23.8448 6.75165L21.3711 17.5019H27.3159C29.3589 17.5019 31.0732 16.0885 31.5408 14.183L33.9975 3.50382H28.0555V3.50098Z" fill="#51B748"/>
<path d="M4.03217 7.37699C3.69781 7.6557 3.48246 7.99413 3.41728 8.26431L2.15918 13.9637H2.23002C2.62105 13.9637 2.98942 13.8243 3.32378 13.5484C3.66097 13.2726 3.87349 12.9341 3.95566 12.5445L4.27869 11.0969H6.97908C7.37011 11.0969 7.74131 10.9576 8.07851 10.6817C8.4157 10.4058 8.63105 10.0646 8.71606 9.67208L8.73023 9.60098H4.61305L4.86524 8.46055H8.76706C9.1581 8.46055 9.52646 8.32119 9.86366 8.04817C10.198 7.7723 10.4049 7.42818 10.4955 7.03855L10.5125 6.96745H5.12593C4.73489 6.96176 4.36653 7.10112 4.03217 7.37699Z" fill="white"/>
<path d="M31.52 7.30069C31.3047 7.08455 31.0213 6.97363 30.6813 6.97363H25.2975L25.289 7.02198C25.2691 7.12721 25.2578 7.22675 25.2578 7.3206C25.2578 7.6505 25.3683 7.92637 25.5837 8.14535C25.8019 8.3615 26.0824 8.47241 26.4252 8.47241H27.587L26.4196 13.9642H26.4932C26.8843 13.9642 27.2498 13.8248 27.5842 13.5518C27.9185 13.2759 28.1254 12.9375 28.2076 12.545L29.0718 8.46957H31.809L31.8175 8.42122C31.8374 8.32168 31.8487 8.21645 31.8487 8.11407C31.8459 7.78986 31.7354 7.51683 31.52 7.30069Z" fill="white"/>
<path d="M19.7101 6.96223H16.0718L16.0747 6.95654H14.5785L13.0938 13.9641H13.1646C13.5556 13.9641 13.924 13.8248 14.2555 13.5489C14.587 13.273 14.7967 12.9346 14.8789 12.545L15.1821 11.1059H18.8544C19.2454 11.1059 19.611 10.9666 19.9453 10.6935C20.2768 10.4205 20.4894 10.0792 20.5772 9.68108L20.8521 8.41551C20.8719 8.31597 20.8832 8.21359 20.8832 8.10836C20.8832 7.78414 20.7727 7.51112 20.5517 7.29213C20.3364 7.07315 20.0502 6.96223 19.7101 6.96223ZM15.7488 8.46101H19.3531L19.1038 9.60714H15.4995L15.7488 8.46101Z" fill="white"/>
</svg>
<h1 style="font-size:3.2em;margin-bottom:0.4em">Cowork-Local BamBOO</h1>
<p style="font-size:1.6em;opacity:.85">Enterprise AI Business Assistant</p>
<span class="slide-number">Slide 1 / 11</span>
</section><section class="slide slide--very-sparse" id="s2">
<h2>Mục đích ứng dụng</h2>
<div class="card-grid grid2">
<div class="card">
<div class="card-title">Vấn đề</div>
<ul>
<li>Nhân viên văn phòng cần AI hỗ trợ tác vụ hàng ngày: tổng hợp báo cáo, phân tích dữ liệu, tạo tài liệu.</li>
<li>Giải pháp hiện tại quá đơn giản (chat thuần túy) hoặc quá phức tạp (cần kiến thức lập trình).</li>
<li>Thiếu công cụ AI <strong>doanh nghiệp</strong>: bảo mật, quản lý tài khoản, tích hợp hệ thống văn phòng.</li>
</ul>
</div>
<div class="card">
<div class="card-title">Giải pháp — Cowork-Local BamBOO</div>
<ul>
<li>Ứng dụng desktop AI đa năng cho doanh nghiệp.</li>
<li>Hỗ trợ 3 ngôn ngữ: Việt, Anh, Nhật.</li>
<li>Tích hợp Microsoft 365 (OneDrive, SharePoint).</li>
<li>Quản lý tài khoản (Admin/Sub-admin/User), phân quyền, giám sát chi phí.</li>
<li><strong>Không cần kiến thức lập trình</strong> — dùng như chat, kết quả là file thực tế.</li>
</ul>
</div>
</div>
<span class="slide-number">Slide 2 / 13</span>
</section><section class="slide slide--very-sparse" id="s3">
<h2>Kiến trúc tổng quan</h2>
<div class="arch-wrap" style="grid-template-columns:repeat(4,1fr)">
<div class="arch-layer">
<div class="arch-layer-title">UI Layer (PySide6/Qt)</div>
<div class="arch-node blue">Dashboard<small>thống kê chi phí</small></div>
<div class="arch-node blue">Schedule<small>Kanban board</small></div>
<div class="arch-node blue">Workspace<small>5 sub-tabs</small></div>
<div class="arch-node blue">Monitoring<small>Security · MCP · Logs</small></div>
<div class="arch-node blue">Settings · Login · Help</div>
</div>
<div class="arch-layer">
<div class="arch-layer-title">Core Business Logic</div>
<div class="arch-node green">Chat Agent<small>Cowork</small></div>
<div class="arch-node green">Code Agent</div>
<div class="arch-node green">Co4E Workflow<small>DAG multi-agent</small></div>
<div class="arch-node green">Schedule Task<small>cron + chaining</small></div>
<div class="arch-node green">Projects · Accounts · Skills</div>
<div class="arch-node green">Doc Extract · PPTX · XLSX</div>
</div>
<div class="arch-layer">
<div class="arch-layer-title">Routing &amp; Providers</div>
<div class="arch-node purple">Auto Model Routing<small>classify → score → select</small></div>
<div class="arch-node purple">OpenAI Compatible</div>
<div class="arch-node purple">Anthropic · Ollama</div>
<div class="arch-node purple">Copilot · Codex</div>
<div class="arch-node purple">Model Pricing · Benchmark</div>
</div>
<div class="arch-layer">
<div class="arch-layer-title">Security &amp; Integration</div>
<div class="arch-node orange">Agent Security<small>3 lớp bảo mật</small></div>
<div class="arch-node orange">Sandbox Manager<small>risk-based</small></div>
<div class="arch-node orange">MCP Client + Servers</div>
<div class="arch-node orange">MS365 · Jira · Ext</div>
<div class="arch-node orange">Audit Log · Usage Tracker</div>
<div class="arch-node orange">Doc Extract · Image Gen</div>
</div>
</div>
<div class="highlight-box"><strong>Bảo mật xuyên suốt:</strong> Risk Classifier → Backend Selector → Execution (Direct / Integrity Job / AppContainer / Win Sandbox).</div>
<div class="footnote">Kiến trúc 4 lớp: UI → Core → Routing/Providers → Security/Integration. Mỗi layer có thể mở rộng độc lập. Tổng cộng 50+ module trong core/, 30+ UI components. Hỗ trợ Windows, macOS, Linux. I18n: Tiếng Việt, English, 日本語. Dark/Light theme tích hợp sẵn.</div>
<div class="accent-box" style="margin-top:0.6rem"><strong>Thiết kế modular:</strong> mỗi layer giao tiếp qua interface rõ ràng, dễ dàng thay thế hoặc mở rộng thành phần.</div>
<span class="slide-number">Slide 3 / 13</span>
</section><section class="slide slide--very-sparse" id="s4">
<h2>Luồng xử lý chính: Chat Cowork</h2>
<div class="flow-row">
<div class="flow-node">User Input + file</div>
<span class="flow-arrow">→</span>
<div class="flow-node accent">Chat Agent<small>apply skills · rules · project ctx</small></div>
<span class="flow-arrow">→</span>
<div class="flow-node">Auto Model Routing<small>classify → rank → switch</small></div>
<span class="flow-arrow">→</span>
<div class="flow-node">Agent Security L1<small>prompt validate</small></div>
</div>
<div class="flow-row">
<div class="flow-node">Provider Chat Loop<small>streaming response</small></div>
<span class="flow-arrow">→</span>
<div class="flow-node accent">Tool Calling<small>file / command / MCP / MS365</small></div>
<span class="flow-arrow">→</span>
<div class="flow-node">Security L2+L3<small>attachment + command check</small></div>
<span class="flow-arrow">→</span>
<div class="flow-node green">Output Files<small>.xlsx · .pptx · .docx · .md</small></div>
</div>
<div class="highlight-box"><strong>Kết quả là file thực tế</strong> — không chỉ là câu trả lời chat, AI tạo ra tài liệu/báo cáo có thể dùng ngay.</div>
<div class="footnote">Tool calling hỗ trợ: file I/O, command execution, MCP connectors, MS365 Graph API, Jira, và external connectors framework.</div>
<div class="accent-box" style="margin-top:1rem"><strong>Đa provider:</strong> OpenAI, Anthropic, Ollama, GitHub Copilot, Codex — tự động chọn model phù hợp.</div>
<span class="slide-number">Slide 4 / 13</span>
</section>
<section class="slide slide--very-sparse" id="s5">
<h2>Luồng xử lý: Co4E Workflow</h2>
<div class="flow-row">
<div class="flow-node">Wave 0<small>Step A</small></div>
<span class="flow-arrow">→</span>
<div class="flow-node accent">Wave 1 (parallel)<small>Sub 1 + Sub 2 chạy đồng thời</small></div>
<span class="flow-arrow">→</span>
<div class="flow-node">Wave 2 (join)<small>Coordinator tổng hợp</small></div>
</div>
<div class="card-grid grid3">
<div class="card">
<div class="card-title">Mỗi step</div>
<ul>
<li>Agent persona (built-in / custom)</li>
<li>Model riêng</li>
<li>Permission preset</li>
<li>Self-verify (quality gate)</li>
<li>Skills đính kèm</li>
</ul>
</div>
<div class="card">
<div class="card-title">Run modes</div>
<ul>
<li><strong>Auto</strong> — AI tự thực hiện</li>
<li><strong>Plan</strong> — read-only</li>
<li><strong>Manual</strong> — step-by-step</li>
</ul>
</div>
<div class="card">
<div class="card-title">Lưu ý</div>
<ul>
<li>Workflow là DAG — không có retry/loop/condition/branch tự động.</li>
<li>Parallel node chạy sub-agent đồng thời + join stage.</li>
</ul>
</div>
</div>
<span class="slide-number">Slide 5 / 13</span>
</section>
<section class="slide slide--very-sparse" id="s6">
<h2>Luồng xử lý: Schedule Task</h2>
<div class="flow-row">
<div class="flow-node">Backlog</div>
<span class="flow-arrow">→</span>
<div class="flow-node accent">Scheduled</div>
<span class="flow-arrow">→</span>
<div class="flow-node">Running</div>
<span class="flow-arrow">→</span>
<div class="flow-node green">Done</div>
</div>
<div class="card-grid grid3">
<div class="card">
<div class="card-title">Trạng thái phụ</div>
<ul>
<li>Paused</li>
<li>Failed</li>
<li>Waiting Input</li>
</ul>
</div>
<div class="card">
<div class="card-title">Lập lịch</div>
<ul>
<li>One-shot · Daily · Weekly · Monthly · Cron</li>
<li>Skip: working days + holiday calendar</li>
<li>Task chaining (fan-in depends_on)</li>
</ul>
</div>
<div class="card">
<div class="card-title">Kiểm soát</div>
<ul>
<li>Retry: max_retry</li>
<li>Timeout: per-task (600s)</li>
<li>Notify: Teams webhook / Outlook desktop</li>
</ul>
</div>
</div>
<div class="footnote">Hỗ trợ import task từ CSV/Excel, tự động chain theo thứ tự, và lịch nghỉ lễ (VN/JP/US/KR…).</div>
<span class="slide-number">Slide 6 / 13</span>
</section><section class="slide" id="s7">
<h2>Chức năng hiện tại (1/4)</h2>
<h3>Chat &amp; Agent</h3>
<table>
<thead><tr><th>Chức năng</th><th>Mô tả</th><th>Trạng thái</th></tr></thead>
<tbody>
<tr><td><strong>Cowork Chat</strong></td><td>Chat với AI, đính kèm file, nhận output file thực tế</td><td>✅ Hoàn chỉnh</td></tr>
<tr><td><strong>Code Agent</strong></td><td>Agent chuyên biệt cho task phát triển phần mềm</td><td>✅ Hoàn chỉnh</td></tr>
<tr><td><strong>AI Edit</strong></td><td>Chỉnh sửa file bằng AI, hỗ trợ tạo ảnh minh họa</td><td>✅ Hoàn chỉnh</td></tr>
<tr><td><strong>Help Agent</strong></td><td>Trợ lý hỗ trợ sử dụng app, luôn sẵn sàng</td><td>✅ Hoàn chỉnh</td></tr>
<tr><td><strong>Multi-provider</strong></td><td>OpenAI, Anthropic, Ollama, GitHub Copilot, Codex</td><td>✅ Hoàn chỉnh</td></tr>
</tbody>
</table>
<h3>Workspace &amp; Project</h3>
<table>
<thead><tr><th>Chức năng</th><th>Mô tả</th><th>Trạng thái</th></tr></thead>
<tbody>
<tr><td><strong>Projects</strong></td><td>Mỗi project có instructions + sandbox riêng</td><td>✅ Hoàn chỉnh</td></tr>
<tr><td><strong>Structure Graph</strong></td><td>Đồ thị cấu trúc từ code/tài liệu (AST-based)</td><td>✅ Hoàn chỉnh</td></tr>
<tr><td><strong>Folder Viewer</strong></td><td>Xem &amp; chỉnh sửa file (PDF/DOCX/XLSX)</td><td>✅ Hoàn chỉnh</td></tr>
<tr><td><strong>Terminal</strong></td><td>Terminal tích hợp trong app</td><td>✅ Hoàn chỉnh</td></tr>
</tbody>
</table>
<div class="footnote">Tổng cộng 9 tính năng trong nhóm Chat & Agent và Workspace & Project, tất cả đã hoàn chỉnh và sẵn sàng sử dụng. Multi-provider hỗ trợ OpenAI, Anthropic, Ollama, GitHub Copilot, Codex.</div>
<span class="slide-number">Slide 7 / 13</span>
</section>
<section class="slide slide--sparse" id="s8">
<h2>Chức năng hiện tại (2/4) — Automation · Integration</h2>
<h3>Automation</h3>
<table>
<thead><tr><th>Chức năng</th><th>Mô tả</th><th>Trạng thái</th></tr></thead>
<tbody>
<tr><td><strong>Co4E Workflow</strong></td><td>DAG workflow đa bước, multi-agent, chạy song song</td><td>✅ Hoàn chỉnh</td></tr>
<tr><td><strong>Schedule Task</strong></td><td>Lên lịch task tự động, Kanban board, cron, chaining</td><td>✅ Hoàn chỉnh</td></tr>
<tr><td><strong>Skills</strong></td><td>Thư viện skill tích hợp sẵn (5 skills), Skill Manager</td><td>✅ Hoàn chỉnh</td></tr>
<tr><td><strong>Plan Checklist</strong></td><td>Agent tự động tạo &amp; theo dõi checklist công việc</td><td>✅ Hoàn chỉnh</td></tr>
</tbody>
</table>
<h3>Integration</h3>
<table>
<thead><tr><th>Chức năng</th><th>Mô tả</th><th>Trạng thái</th></tr></thead>
<tbody>
<tr><td><strong>Microsoft 365</strong></td><td>OneDrive (đọc/ghi), SharePoint (đọc) — auto-connect</td><td>✅ Hoàn chỉnh</td></tr>
<tr><td><strong>MCP Connectors</strong></td><td>Kết nối external tools qua Model Context Protocol</td><td>✅ Hoàn chỉnh</td></tr>
<tr><td><strong>Jira</strong></td><td>Read-only: search issues, get issue details</td><td>✅ Hoàn chỉnh</td></tr>
<tr><td><strong>Ext Connectors</strong></td><td>Framework CAD/CAE/MS365/Other</td><td>✅ Hoàn chỉnh</td></tr>
</tbody>
</table>
<span class="slide-number">Slide 8 / 13</span>
</section>
<section class="slide slide--very-sparse" id="s9">
<h2>Chức năng hiện tại (3/4) — Administration</h2>
<table>
<thead><tr><th>Chức năng</th><th>Mô tả</th><th>Trạng thái</th></tr></thead>
<tbody>
<tr><td><strong>Tài khoản &amp; RBAC</strong></td><td>Admin / Sub-admin / User, import/export Excel</td><td>✅ Hoàn chỉnh</td></tr>
<tr><td><strong>Groups</strong></td><td>Nhóm tài khoản để phân quyền theo nhóm</td><td>✅ Hoàn chỉnh</td></tr>
<tr><td><strong>Dashboard</strong></td><td>Thống kê token usage &amp; chi phí, biểu đồ spline</td><td>✅ Hoàn chỉnh</td></tr>
<tr><td><strong>Auto Model Routing</strong></td><td>Benchmark model, tự động định tuyến (Auto/Manual/Off)</td><td>✅ Hoàn chỉnh</td></tr>
<tr><td><strong>Agent Security</strong></td><td>3 lớp bảo mật: prompt, attachment, command</td><td>✅ Hoàn chỉnh</td></tr>
<tr><td><strong>Sandbox</strong></td><td>Risk-based: Direct/Integrity/AppContainer/Win Sandbox</td><td>✅ Hoàn chỉnh</td></tr>
<tr><td><strong>Monitoring</strong></td><td>Overview, Security, MCP, Logs, Agent Status</td><td>✅ Hoàn chỉnh</td></tr>
<tr><td><strong>Audit Log</strong></td><td>tool_call, permission, security_block, mcp_call</td><td>✅ Hoàn chỉnh</td></tr>
<tr><td><strong>Model Pricing</strong></td><td>Bảng giá model, tùy chỉnh USD/token</td><td>✅ Hoàn chỉnh</td></tr>
</tbody>
</table>
<span class="slide-number">Slide 9 / 13</span>
</section>
<section class="slide slide--very-sparse" id="s10">
<h2>Chức năng hiện tại (4/4) — Document &amp; File</h2>
<table>
<thead><tr><th>Chức năng</th><th>Mô tả</th><th>Trạng thái</th></tr></thead>
<tbody>
<tr><td><strong>Doc Extract</strong></td><td>Trích xuất text từ PDF/DOCX/XLSX/PPTX/images</td><td>✅ Hoàn chỉnh</td></tr>
<tr><td><strong>PPTX Edit</strong></td><td>Tạo và chỉnh sửa PowerPoint files</td><td>✅ Hoàn chỉnh</td></tr>
<tr><td><strong>XLSX Write</strong></td><td>Tạo Excel files với styling</td><td>✅ Hoàn chỉnh</td></tr>
<tr><td><strong>Image Gen</strong></td><td>Tạo ảnh bằng AI</td><td>✅ Hoàn chỉnh</td></tr>
<tr><td><strong>Link Fetch</strong></td><td>Fetch URL preview cho task attachments</td><td>✅ Hoàn chỉnh</td></tr>
</tbody>
</table>
<div class="highlight-box"><strong>Tổng cộng:</strong> 30+ tính năng đã hoàn chỉnh, sẵn sàng dùng trong doanh nghiệp.</div>
<div class="footnote">Doc Extract hỗ trợ PDF, DOCX, XLSX, PPTX, images. PPTX Edit tạo và chỉnh sửa slide với font/styling. XLSX Write tạo Excel có màu sắc, border.</div>
<div class="accent-box" style="margin-top:1rem"><strong>AI tạo file trực tiếp</strong> — từ câu lệnh chat, AI sinh ra tài liệu/báo cáo/ảnh dùng ngay được.</div>
<span class="slide-number">Slide 10 / 13</span>
</section><section class="slide slide--sparse" id="s11">
<h2>Hướng dẫn build &amp; chạy ứng dụng</h2>
<div class="card-grid grid2">
<div class="card">
<div class="card-title">Yêu cầu hệ thống</div>
<ul>
<li>Python 3.10+ (khuyến nghị 3.11/3.12)</li>
<li>OS: Windows 10/11, macOS, Linux</li>
<li>Network: cần internet để cài dependencies &amp; gọi API AI</li>
</ul>
<div class="card-title" style="margin-top:1rem">Cài &amp; chạy</div>
<ul>
<li><code>pip install -r requirements.txt</code></li>
<li><code>python -m cowork_local</code> hoặc <code>python __main__.py</code></li>
</ul>
</div>
<div class="card">
<div class="card-title">Cấu hình API key</div>
<ul>
<li>Settings (⚙) → chọn provider → nhập API key &amp; base URL.</li>
<li>Hoặc đặt qua environment variables: <code>OPENAI_API_KEY</code>, <code>OPENAI_BASE_URL</code>…</li>
</ul>
<div class="card-title" style="margin-top:1rem">Lưu ý</div>
<ul>
<li>Cấu hình lưu tại <code>~/.cowork_local/config.json</code></li>
<li>Một số package (như <code>opendataloader-pdf</code>) tự cài khi cần lần đầu.</li>
<li>Lỗi <code>No module named cowork_local</code> → chạy <code>python __main__.py</code>.</li>
</ul>
</div>
</div>
<span class="slide-number">Slide 11 / 13</span>
</section>
<section class="slide slide--sparse" id="s12">
<h2>Kịch bản Demo</h2>
<div class="card-grid grid3">
<div class="card">
<div class="card-title">Demo 1 — File → AI → Báo cáo + OneDrive</div>
<ul>
<li>Mở Cowork Chat, đính kèm file báo cáo doanh thu (.xlsx/.pdf).</li>
<li>Gõ: phân tích số liệu, tạo báo cáo .xlsx có màu + viết .md lên OneDrive.</li>
<li>AI: đọc → phân tích → tạo Excel → upload text lên OneDrive → trả link.</li>
</ul>
<div class="footnote">Demo 1: OneDrive write chỉ hỗ trợ text files (.md, .txt). Demo 2: Teams webhook + Outlook desktop notification. Demo 3: Auto mode chạy toàn bộ workflow tự động.</div>
</div>
<div class="card">
<div class="card-title">Demo 2 — Schedule Task tự động</div>
<ul>
<li>Vào Schedule → tạo task, đặt lịch "8h sáng thứ 2 hàng tuần".</li>
<li>Nội dung: đọc file doanh thu, phân tích, tạo báo cáo .xlsx.</li>
<li>Bật <code>working_days_only</code> + <code>skip_holidays</code>.</li>
<li>Task tự chạy, kết quả lưu trong task artifacts.</li>
</ul>
</div>
<div class="card">
<div class="card-title">Demo 3 — Co4E Workflow phân tích dự án</div>
<ul>
<li>Step 1 (Research): đọc code, phân tích kiến trúc.</li>
<li>Step 2 (Implement - parallel): 2 sub-agent cùng chạy (unit test + docs).</li>
<li>Step 3 (Join + Review): tổng hợp, kiểm tra chất lượng.</li>
<li>Chạy Auto mode → AI tự thực hiện từng bước.</li>
</ul>
</div>
</div>
<span class="slide-number">Slide 12 / 13</span>
</section>
<section class="slide slide--very-sparse" id="s13">
<h2>Tóm tắt</h2>
<div class="card-grid grid3">
<div class="card">
<div class="card-title">Dễ dùng &amp; Đa năng</div>
<ul>
<li>Giao diện chat, không cần code.</li>
<li>Chat, workflow, schedule, code, Structure Graph, skills.</li>
</ul>
</div>
<div class="card">
<div class="card-title">Bảo mật &amp; Tiết kiệm</div>
<ul>
<li>3 lớp Agent Security + Sandbox risk-based.</li>
<li>Auto Model Routing, theo dõi chi phí.</li>
</ul>
</div>
<div class="card">
<div class="card-title">Tích hợp &amp; Quản trị</div>
<ul>
<li>MS365 (OneDrive/SharePoint), MCP, Jira.</li>
<li>RBAC, Dashboard, Audit Log, Groups.</li>
</ul>
</div>
</div>
<div class="highlight-box"><strong>Cowork-Local BamBOO</strong> — công cụ AI doanh nghiệp toàn diện: dễ dùng, bảo mật, tiết kiệm, tích hợp, quản trị, đa năng.</div>
<div class="accent-box" style="margin-top:1rem"><strong>30+ tính năng đã hoàn chỉnh</strong> — sẵn sàng triển khai trong doanh nghiệp ngay hôm nay.</div>
<span class="slide-number">Slide 13 / 13</span>
</section></div>
<script>
function toggleTheme(){
const h=document.documentElement,b=document.querySelector('.theme-toggle'),d=h.getAttribute('data-theme')==='dark';
h.setAttribute('data-theme',d?'light':'dark');b.textContent=d?'🌙':'☀️';
}
if(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches){
document.documentElement.setAttribute('data-theme','dark');
document.querySelector('.theme-toggle').textContent='☀️';
}
// Inject FPT brand mark (default) into every slide — see "Brand Logo" section for SVG constant
const BRAND_SVG='<svg class="brand-mark" viewBox="0 0 34 21" xmlns="http://www.w3.org/2000/svg"><path d="M6.68439 3.50089C4.75756 3.50089 3.12259 4.75793 2.55021 6.5013C2.53888 6.54111 2.52471 6.58093 2.51338 6.6179L2.41703 6.99331L0 17.499H6.08934C7.90849 17.499 9.45845 16.3415 10.0478 14.7204L10.2774 13.7193L12.6292 3.49805H6.68439V3.50089Z" fill="#08509F"/><path d="M18.1691 0C16.18 0 14.5025 1.34236 13.984 3.17389C13.9443 3.3104 13.9131 3.44976 13.8876 3.59196L9.88379 21H15.8286C17.866 21 19.5746 19.5951 20.0506 17.6981H20.0535L24.1196 0H18.1691Z" fill="#F27123"/><path d="M28.0555 3.50098C26.1967 3.50098 24.6099 4.6727 23.9865 6.31937C23.9553 6.40469 23.8448 6.75165 23.8448 6.75165L21.3711 17.5019H27.3159C29.3589 17.5019 31.0732 16.0885 31.5408 14.183L33.9975 3.50382H28.0555V3.50098Z" fill="#51B748"/><path d="M4.03217 7.37699C3.69781 7.6557 3.48246 7.99413 3.41728 8.26431L2.15918 13.9637H2.23002C2.62105 13.9637 2.98942 13.8243 3.32378 13.5484C3.66097 13.2726 3.87349 12.9341 3.95566 12.5445L4.27869 11.0969H6.97908C7.37011 11.0969 7.74131 10.9576 8.07851 10.6817C8.4157 10.4058 8.63105 10.0646 8.71606 9.67208L8.73023 9.60098H4.61305L4.86524 8.46055H8.76706C9.1581 8.46055 9.52646 8.32119 9.86366 8.04817C10.198 7.7723 10.4049 7.42818 10.4955 7.03855L10.5125 6.96745H5.12593C4.73489 6.96176 4.36653 7.10112 4.03217 7.37699Z" fill="white"/><path d="M31.52 7.30069C31.3047 7.08455 31.0213 6.97363 30.6813 6.97363H25.2975L25.289 7.02198C25.2691 7.12721 25.2578 7.22675 25.2578 7.3206C25.2578 7.6505 25.3683 7.92637 25.5837 8.14535C25.8019 8.3615 26.0824 8.47241 26.4252 8.47241H27.587L26.4196 13.9642H26.4932C26.8843 13.9642 27.2498 13.8248 27.5842 13.5518C27.9185 13.2759 28.1254 12.9375 28.2076 12.545L29.0718 8.46957H31.809L31.8175 8.42122C31.8374 8.32168 31.8487 8.21645 31.8487 8.11407C31.8459 7.78986 31.7354 7.51683 31.52 7.30069Z" fill="white"/><path d="M19.7101 6.96223H16.0718L16.0747 6.95654H14.5785L13.0938 13.9641H13.1646C13.5556 13.9641 13.924 13.8248 14.2555 13.5489C14.587 13.273 14.7967 12.9346 14.8789 12.545L15.1821 11.1059H18.8544C19.2454 11.1059 19.611 10.9666 19.9453 10.6935C20.2768 10.4205 20.4894 10.0792 20.5772 9.68108L20.8521 8.41551C20.8719 8.31597 20.8832 8.21359 20.8832 8.10836C20.8832 7.78414 20.7727 7.51112 20.5517 7.29213C20.3364 7.07315 20.0502 6.96223 19.7101 6.96223ZM15.7488 8.46101H19.3531L19.1038 9.60714H15.4995L15.7488 8.46101Z" fill="white"/></svg>';
document.querySelectorAll('.slide').forEach(s=>{if(!s.querySelector('.brand-mark'))s.insertAdjacentHTML('beforeend',BRAND_SVG);});
// Wrap each slide for viewport scaling
document.querySelectorAll('.slide').forEach(s=>{
const w=document.createElement('div');w.className='slide-wrap';
s.parentNode.insertBefore(w,s);w.appendChild(s);
});
// Build thumbnail sidebar (cloned slides at 0.1x)
const thumbNav=document.getElementById('thumb-nav');
const slides=document.querySelectorAll('.slide');
slides.forEach((s,i)=>{
const t=document.createElement('div');t.className='thumb';t.dataset.idx=i;
t.innerHTML='<span class="thumb-num">'+(i+1)+'</span>';
const inner=document.createElement('div');inner.className='thumb-inner';
inner.appendChild(s.cloneNode(true));
t.appendChild(inner);
t.onclick=()=>slides[i].scrollIntoView({behavior:'smooth',block:'start'});
thumbNav.appendChild(t);
});
const thumbs=thumbNav.querySelectorAll('.thumb');
function toggleThumbs(){document.body.classList.toggle('thumbs-open');thumbNav.classList.toggle('open');setTimeout(applyScale,0);}
function highlightThumb(){
const i=currentIndex();thumbs.forEach((t,j)=>t.classList.toggle('active',j===i));
const a=thumbs[i];if(a&&thumbNav.classList.contains('open')){
const r=a.getBoundingClientRect(),nr=thumbNav.getBoundingClientRect();
if(r.top<nr.top||r.bottom>nr.bottom)a.scrollIntoView({block:'nearest'});
}
}
function applyScale(){
const navH=54,sideW=document.body.classList.contains('thumbs-open')?220:0;
const vw=window.innerWidth-sideW,vh=window.innerHeight-navH;
const scale=Math.min(vw/1920,vh/1080,1);
document.documentElement.style.setProperty('--scale',scale);
document.documentElement.style.setProperty('--scaled-w',(1920*scale)+'px');
document.documentElement.style.setProperty('--scaled-h',(1080*scale)+'px');
}
applyScale();window.addEventListener('resize',applyScale);
function currentIndex(){
const y=window.scrollY+window.innerHeight/2;
for(let i=0;i<slides.length;i++){
const r=slides[i].getBoundingClientRect();
if(r.top+window.scrollY<=y&&r.bottom+window.scrollY>=y)return i;
}
return 0;
}
function updateNav(){const p=document.getElementById('nav-pos');if(p)p.textContent=(currentIndex()+1)+' / '+slides.length;highlightThumb();}
function prevSlide(){const i=currentIndex();if(i>0)slides[i-1].scrollIntoView({behavior:'smooth'});}
function nextSlide(){const i=currentIndex();if(i<slides.length-1)slides[i+1].scrollIntoView({behavior:'smooth'});}
window.addEventListener('scroll',updateNav);
document.addEventListener('keydown',e=>{if(e.key==='ArrowRight'||e.key==='ArrowDown')nextSlide();if(e.key==='ArrowLeft'||e.key==='ArrowUp')prevSlide();});
updateNav();
</script>
</body>
</html>
Binary file not shown.
Binary file not shown.
+615 -251
View File
@@ -1,282 +1,604 @@
"""Qt style sheets giving the app a modern, dark design-tool look (deep
ocean-blue surfaces, large rounded corners, a deep-sea gradient accent, and
colorful pill badges) inspired by contemporary dashboard UIs."""
"""The app's visual system: semantic design tokens + one style sheet template.
This replaces the previous mechanism, which was two hand-written Qt style
sheets (``_DARK`` / ``_LIGHT``) that duplicated each other and hard-coded ~105
hex literals, with a single source of truth:
Palette (tokens) -> _TEMPLATE (one QSS) -> stylesheet(theme)
Rules of the system
-------------------
* **Nothing outside this module names a colour.** Widgets that paint with
``QPainter`` (charts, canvases, syntax highlighters) call :func:`palette` and
read a token. Widgets that style themselves declaratively should instead be
given an ``objectName`` and styled in ``_TEMPLATE`` below.
* **Tokens are semantic, not literal.** ``danger``/``text_muted``/``code_string``
— never ``blue``/``grey2``. Adding a theme means adding a :class:`Palette`,
not editing a style sheet.
* **No gradients, no glows.** The palette is Visual Studio Code's — "Dark
Modern" and "Light Modern", taken from the shipped theme JSON. Flat surfaces,
square-ish corners, one accent spent only on what the user acts on. Depth
comes from the surface ramp and hairline borders, not from colour. Note the
VS Code silhouette: the nav rail is *darker* than the content area, not
lighter.
Contrast is held to WCAG AA (4.5:1) for body text and for text on filled
buttons. That is why ``accent`` and ``accent_solid`` are separate tokens: on a
dark background a blue readable *as text* is too light to carry white *as a
fill*, so each role gets the tint that passes.
Four VS Code values fall below AA and are nudged just far enough to clear it —
dark line numbers (3.59:1), light faint text on the sidebar (4.28:1), light
green (4.33:1) and light amber (3.12:1). Each carries a comment naming the
original value, so the deviation is auditable rather than silent.
"""
from __future__ import annotations
# Brand accent — deep sea blue palette with teal-cyan gradients.
ACCENT = "#0096C7"
ACCENT_HOVER = "#48CAE4"
ACCENT2 = "#0077B6" # deeper ocean blue for gradients
GRADIENT = f"qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 {ACCENT2}, stop:1 {ACCENT})"
GRADIENT_HOVER = f"qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #023E8A, stop:1 {ACCENT_HOVER})"
from dataclasses import dataclass, asdict
from string import Template
_DARK = f"""
* {{ font-family: "Segoe UI", "Helvetica Neue", "Arial", "Yu Gothic UI", "Meiryo", sans-serif; font-size: 13px; }}
QMainWindow, QWidget {{ background: #0A1628; color: #E0F0FF; }}
QMainWindow::separator {{ background: #0A1628; width: 4px; height: 4px; }}
QStatusBar {{ background: #0A1628; color: #5C8DB8; border-top: 1px solid #132240; }}
QSplitter::handle {{ background: #0A1628; }}
/* Separate the nav rail (its own panel + divider) from the content area. */
QWidget#navWrap {{ background: #0D1F35; border-right: 1px solid #17263f; }}
QWidget#navWrap QTreeWidget, QWidget#navWrap QListWidget {{ background: transparent; border: none; }}
QWidget#contentArea {{ background: #0A1628; }}
/* Compact icons app-wide (they looked oversized on scaled displays). */
QPushButton, QToolButton, QComboBox, QTabBar {{ qproperty-iconSize: 14px 14px; }}
QTreeWidget#navrail {{ qproperty-iconSize: 16px 16px; }}
/* Square the pane (tabs above stay rounded): a rounded pane lets its square
child pages poke past the corners — the "rectangle behind the rounded box". */
QTabWidget::pane {{ background: #0D1F35; border: 1px solid #132240; border-radius: 0px; top: 2px; }}
QTabBar {{ background: transparent; }}
QTabBar::tab {{
background: #111D32; color: #5C8DB8; padding: 9px 22px; margin: 0 6px 8px 0;
border-radius: 10px; border: 1px solid #132240; font-weight: 500;
}}
QTabBar::tab:selected {{ background: {GRADIENT}; color: white; border: none; font-weight: 700; }}
QTabBar::tab:hover:!selected {{ background: #172A45; color: #B0D4F1; }}
/* Co4E flow "browser" tabs: sit FLUSH in their row (no floating bottom gap, so
the icon/label is vertically centred) and use the app's panel/accent surfaces
so the strip reads as part of the app. */
QTabBar#flowTabs::tab {{ background: #0D1F35; color: #8FB2D4; border: 1px solid #17263f;
padding: 6px 10px; margin: 0 3px 0 0; min-height: 22px; border-radius: 8px; }}
QTabBar#flowTabs::tab:selected {{ background: {GRADIENT}; color: white; border: 1px solid transparent; }}
QTabBar#flowTabs::tab:hover:!selected {{ background: #172A45; color: #E0F0FF; }}
/* The "new flow" (+) button — styled as the last tab in the strip. */
QPushButton#flowAddBtn {{ background: #0D1F35; color: #8FB2D4; border: 1px solid #17263f;
border-radius: 8px; padding: 6px 0; font-size: 15px; font-weight: bold; min-height: 22px; }}
QPushButton#flowAddBtn:hover {{ background: #172A45; color: #E0F0FF; }}
/* Co4E sidebar (Workflows/Agents/Skills): transparent icon tabs with a subtle
translucent selection + the normal text colour (matches the app's lists,
not a bright fill). */
QTabBar#co4eSideTabs {{ qproperty-iconSize: 18px 18px; }}
QTabBar#co4eSideTabs::tab {{ background: transparent; color: #8FB2D4; border: none;
border-radius: 6px; padding: 5px; margin: 0 6px 0 0; }}
QTabBar#co4eSideTabs::tab:selected {{ background: rgba(0,150,199,0.28); color: #E0F0FF; }}
QTabBar#co4eSideTabs::tab:hover:!selected {{ background: #172A45; color: #B0D4F1; }}
/* Co4E canvas frame — match the app's other framed surfaces (not a faint hairline). */
QGraphicsView#co4eCanvas {{ background: #0D1F35; border: 1px solid #1A2D4A; border-radius: 10px; }}
@dataclass(frozen=True)
class Palette:
"""Every colour and shape value the interface is allowed to use."""
QGroupBox {{
background: #0D1F35; border: 1px solid #132240; border-radius: 18px;
margin-top: 16px; padding: 14px 10px 10px 10px; font-weight: 700;
}}
QGroupBox::title {{
subcontrol-origin: margin; subcontrol-position: top left; left: 12px; top: 2px;
padding: 0 6px; color: #5C8DB8; letter-spacing: 0.5px;
}}
name: str
QPlainTextEdit, QTextEdit, QTextBrowser, QLineEdit, QTreeView, QListView, QTreeWidget, QListWidget {{
background: #111D32; color: #E0F0FF; border: 1px solid #1A2D4A; border-radius: 10px;
selection-background-color: {ACCENT}; selection-color: white;
}}
/* Scroll containers must NOT paint their own square panel behind rounded
children (that square is what shows as a "rectangle under the rounded box").
The corner where scrollbars meet is squared off too — keep it transparent. */
QScrollArea {{ background: transparent; border: none; }}
QAbstractScrollArea::corner {{ background: transparent; }}
QPlainTextEdit:focus, QTextEdit:focus, QLineEdit:focus {{ border: 1px solid {ACCENT}; }}
QTreeView::item, QListView::item {{ padding: 3px 2px; border-radius: 6px; }}
QTreeView::item:hover, QListView::item:hover {{ background: #172A45; }}
QTreeView::item:selected, QListView::item:selected {{ background: rgba(0,150,199,0.28); color: #E0F0FF; }}
QHeaderView::section {{ background: #111D32; color: #5C8DB8; border: none; border-bottom: 1px solid #132240; padding: 6px; }}
# --- surfaces: a 4-step ramp from the window back to the frontmost layer.
bg: str # window / canvas backdrop
surface: str # panels, cards, group boxes (NOT the nav rail)
surface_raised: str # inputs, lists, trees — things you type or pick in
overlay: str # menus, tooltips, popups (floats above everything)
sunken: str # logs, code, terminals — things you read into
hover: str # hover wash on rows, tabs, ghost buttons
active: str # pressed / held state
QPushButton {{
background: #132240; color: #E0F0FF; border: 1px solid #1A2D4A;
border-radius: 10px; padding: 8px 16px;
}}
QPushButton:hover {{ background: #1A3050; border-color: #234070; }}
QPushButton:pressed {{ background: #111D32; }}
QPushButton:disabled {{ color: #3A5A78; background: #0F1A28; border-color: #132240; }}
QPushButton#primary {{ background: {GRADIENT}; color: white; border: none; font-weight: 700; padding: 8px 18px; }}
QPushButton#primary:hover {{ background: {GRADIENT_HOVER}; }}
QPushButton#primary:disabled {{ background: #1A2D4A; color: #3A5A78; }}
QPushButton#danger {{ background: #E5484D; color: white; border: none; font-weight: 600; }}
QPushButton#danger:hover {{ background: #EF6368; }}
QPushButton#navMenuBtn {{
background: transparent; border: none; border-radius: 8px; padding: 5px 6px;
font-weight: 700; font-size: 11px; letter-spacing: 1px; color: #5C8DB8; text-align: left;
}}
QPushButton#navMenuBtn:hover {{ background: #132240; color: #E0F0FF; }}
QPushButton#navMenuBtn:pressed {{ background: #111D32; }}
# The nav rail gets its own step rather than borrowing `surface`. It is a
# permanent region of the window, not a card floating on the page.
#
# Following VS Code, the rail is *darker* than the content area (dark) or a
# shade off white (light). The step is small on purpose — VS Code separates
# the rail with a border, not a big tonal jump — so `nav_border` is doing
# real work here and must stay visible.
nav_bg: str
nav_border: str
nav_hover: str
nav_selected: str
QLabel#badge {{ background: #0A2A3A; color: #48CAE4; border-radius: 11px; padding: 2px 12px; font-weight: 600; }}
QLabel#badgeSuccess {{ background: #0A2A20; color: #48D9A0; border-radius: 11px; padding: 2px 12px; font-weight: 600; }}
QLabel#badgePurple {{ background: #1A1A3A; color: #9B8FF7; border-radius: 11px; padding: 2px 12px; font-weight: 600; }}
QLabel#badgePink {{ background: #2A1A30; color: #D980C0; border-radius: 11px; padding: 2px 12px; font-weight: 600; }}
QLabel#badgeWarn {{ background: #0A2A3A; color: #48CAE4; border-radius: 11px; padding: 2px 12px; font-weight: 600; }}
QLabel#hint {{ color: #5C8DB8; }}
QLabel#warning {{ color: #48CAE4; font-weight: 600; }}
# --- lines
border: str # default hairline
border_strong: str # hairline that must survive next to a filled surface
focus_ring: str # keyboard/typing focus
QComboBox {{ background: #111D32; border: 1px solid #1A2D4A; border-radius: 10px; padding: 6px 10px; }}
QComboBox:hover {{ border-color: #234070; }}
QComboBox::drop-down {{ border: none; width: 22px; }}
QComboBox QAbstractItemView {{
background: #111D32; border: 1px solid #1A2D4A; border-radius: 10px;
selection-background-color: {ACCENT}; selection-color: white; outline: none;
}}
# --- text
text: str
text_muted: str # secondary copy, captions, group-box titles
text_faint: str # metadata, timestamps, placeholder
text_disabled: str
on_accent: str # text drawn on top of a filled accent/status surface
QScrollBar:vertical {{ background: transparent; width: 11px; margin: 2px; }}
QScrollBar::handle:vertical {{ background: #1A2D4A; border-radius: 5px; min-height: 28px; }}
QScrollBar::handle:vertical:hover {{ background: {ACCENT}; }}
QScrollBar:horizontal {{ background: transparent; height: 11px; margin: 2px; }}
QScrollBar::handle:horizontal {{ background: #1A2D4A; border-radius: 5px; min-width: 28px; }}
QScrollBar::handle:horizontal:hover {{ background: {ACCENT}; }}
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; width: 0; border: none; background: none; }}
# --- accent: `accent` tints text & icons, `accent_solid` fills buttons.
accent: str
accent_solid: str
accent_solid_hover: str
accent_solid_active: str
accent_soft: str # translucent wash for selected rows (QSS only)
accent_soft_hover: str
accent_wash: str # the same tint pre-blended to a solid, for Qt rich
# text (bgcolor=, <table>) where alpha is ignored
QCheckBox {{ spacing: 8px; }}
QCheckBox::indicator, QRadioButton::indicator {{
width: 16px; height: 16px; background: #111D32; border: 1px solid #1A2D4A; border-radius: 5px;
}}
QRadioButton::indicator {{ border-radius: 9px; }}
QCheckBox::indicator:hover, QRadioButton::indicator:hover {{ border-color: {ACCENT}; }}
QCheckBox::indicator:checked, QRadioButton::indicator:checked {{
background: {GRADIENT}; border-color: {ACCENT};
}}
QCheckBox::indicator:disabled {{ border-color: #132240; background: #0F1A28; }}
QCheckBox::indicator:checked:disabled, QRadioButton::indicator:checked:disabled {{
background: #1A3050; border-color: #234070;
}}
# --- status
success: str
success_soft: str
warning: str
warning_soft: str
danger: str
danger_solid: str
danger_solid_hover: str
danger_soft: str
info: str
info_soft: str
purple: str
purple_soft: str
pink: str
pink_soft: str
QMenu {{ background: #111D32; color: #E0F0FF; border: 1px solid #1A2D4A; border-radius: 10px; padding: 4px; }}
QMenu::item {{ padding: 6px 16px; border-radius: 6px; }}
QMenu::item:selected {{ background: {ACCENT}; color: white; }}
# --- selection (text selection inside editors and inputs)
selection_bg: str
selection_fg: str
QToolTip {{ background: #172A45; color: #E0F0FF; border: 1px solid #1A2D4A; border-radius: 6px; padding: 4px 8px; }}
"""
# --- scrollbars
scroll_handle: str
scroll_handle_hover: str
_LIGHT = f"""
* {{ font-family: "Segoe UI", "Helvetica Neue", "Arial", "Yu Gothic UI", "Meiryo", sans-serif; font-size: 13px; }}
QMainWindow, QWidget {{ background: #E8F4FD; color: #1A2332; }}
QStatusBar {{ background: #E8F4FD; color: #5C8DB8; border-top: 1px solid #B8D4E8; }}
/* Separate the nav rail (its own panel + divider) from the content area. */
QWidget#navWrap {{ background: #EDF5FB; border-right: 1px solid #C4DBEC; }}
QWidget#navWrap QTreeWidget, QWidget#navWrap QListWidget {{ background: transparent; border: none; }}
QWidget#contentArea {{ background: #E8F4FD; }}
/* Compact icons app-wide (they looked oversized on scaled displays). */
QPushButton, QToolButton, QComboBox, QTabBar {{ qproperty-iconSize: 14px 14px; }}
QTreeWidget#navrail {{ qproperty-iconSize: 16px 16px; }}
# --- code & terminal
code_bg: str
code_fg: str
code_gutter_bg: str
code_gutter_fg: str
code_selection: str
code_comment: str
code_keyword: str
code_type: str
code_func: str
code_attr: str
code_string: str
code_number: str
code_error: str
QTabWidget::pane {{ background: #FFFFFF; border: 1px solid #B8D4E8; border-radius: 0px; top: 2px; }}
QTabBar {{ background: transparent; }}
QTabBar::tab {{
background: #D0E8F5; color: #3A6B8C; padding: 9px 22px; margin: 0 6px 8px 0;
border-radius: 10px; border: 1px solid #B8D4E8; font-weight: 500;
}}
QTabBar::tab:selected {{ background: {GRADIENT}; color: white; border: none; font-weight: 700; }}
QTabBar::tab:hover:!selected {{ background: #B8D4E8; color: #1A2332; }}
/* Co4E flow "browser" tabs — light-theme counterpart (see dark block). */
QTabBar#flowTabs::tab {{ background: #EDF5FB; color: #3A6B8C; border: 1px solid #C4DBEC;
padding: 6px 10px; margin: 0 3px 0 0; min-height: 22px; border-radius: 8px; }}
QTabBar#flowTabs::tab:selected {{ background: {GRADIENT}; color: white; border: 1px solid transparent; }}
QTabBar#flowTabs::tab:hover:!selected {{ background: #D0E8F5; color: #1A2332; }}
/* The "new flow" (+) button (light) — styled as the last tab in the strip. */
QPushButton#flowAddBtn {{ background: #EDF5FB; color: #3A6B8C; border: 1px solid #C4DBEC;
border-radius: 8px; padding: 6px 0; font-size: 15px; font-weight: bold; min-height: 22px; }}
QPushButton#flowAddBtn:hover {{ background: #D0E8F5; color: #1A2332; }}
/* Co4E sidebar (light) — transparent tabs, translucent selection, dark text. */
QTabBar#co4eSideTabs {{ qproperty-iconSize: 18px 18px; }}
QTabBar#co4eSideTabs::tab {{ background: transparent; color: #3A6B8C; border: none;
border-radius: 6px; padding: 5px; margin: 0 6px 0 0; }}
QTabBar#co4eSideTabs::tab:selected {{ background: rgba(0,150,199,0.20); color: #1A2332; }}
QTabBar#co4eSideTabs::tab:hover:!selected {{ background: #D0E8F5; color: #1A2332; }}
/* Co4E canvas frame (light). */
QGraphicsView#co4eCanvas {{ background: #FFFFFF; border: 1px solid #B8D4E8; border-radius: 10px; }}
# --- diff / inline change badges
diff_add_bg: str
diff_add_fg: str
diff_del_bg: str
diff_del_fg: str
QGroupBox {{
background: #FFFFFF; border: 1px solid #B8D4E8; border-radius: 18px;
margin-top: 16px; padding: 14px 10px 10px 10px; font-weight: 700;
}}
QGroupBox::title {{
subcontrol-origin: margin; subcontrol-position: top left; left: 12px; top: 2px;
padding: 0 6px; color: #5C8DB8; letter-spacing: 0.5px;
}}
# --- charts
chart_grid: str
chart_label: str
QPlainTextEdit, QTextEdit, QTextBrowser, QLineEdit, QTreeView, QListView, QTreeWidget, QListWidget {{
background: white; color: #1A2332; border: 1px solid #C0D8EC; border-radius: 10px;
selection-background-color: {ACCENT}; selection-color: white;
}}
QScrollArea {{ background: transparent; border: none; }}
QAbstractScrollArea::corner {{ background: transparent; }}
QPlainTextEdit:focus, QTextEdit:focus, QLineEdit:focus {{ border: 1px solid {ACCENT}; }}
QTreeView::item, QListView::item {{ padding: 3px 2px; border-radius: 6px; }}
QTreeView::item:hover, QListView::item:hover {{ background: #E0EFFA; }}
QTreeView::item:selected, QListView::item:selected {{ background: rgba(0,150,199,0.18); color: #1A2332; }}
QHeaderView::section {{ background: #E8F4FD; color: #5C8DB8; border: none; border-bottom: 1px solid #B8D4E8; padding: 6px; }}
# --- conversation & graph node roles
role_user: str
role_assistant: str
role_tool: str
role_result: str
role_error: str
QPushButton {{
background: white; color: #1A2332; border: 1px solid #C0D8EC;
border-radius: 10px; padding: 8px 16px;
}}
QPushButton:hover {{ background: #E0EFFA; }}
QPushButton:disabled {{ color: #8AA8C0; background: #F0F8FC; }}
QPushButton#primary {{ background: {GRADIENT}; color: white; border: none; font-weight: 700; padding: 8px 18px; }}
QPushButton#primary:hover {{ background: {GRADIENT_HOVER}; }}
QPushButton#danger {{ background: #E5484D; color: white; border: none; font-weight: 600; }}
QPushButton#danger:hover {{ background: #EF6368; }}
QPushButton#navMenuBtn {{
background: transparent; border: none; border-radius: 8px; padding: 5px 6px;
font-weight: 700; font-size: 11px; letter-spacing: 1px; color: #5C8DB8; text-align: left;
}}
QPushButton#navMenuBtn:hover {{ background: #D0E8F5; color: #1A2332; }}
QPushButton#navMenuBtn:pressed {{ background: #B8D4E8; }}
# --- shape & type
radius_sm: int
radius: int
radius_lg: int
font_family: str
font_size: int
font_mono: str
QLabel#badge {{ background: #D0ECF8; color: #0077B6; border-radius: 11px; padding: 2px 12px; font-weight: 600; }}
QLabel#badgeSuccess {{ background: #D0F5E8; color: #1B7A3D; border-radius: 11px; padding: 2px 12px; font-weight: 600; }}
QLabel#badgePurple {{ background: #E8E0FF; color: #6238C9; border-radius: 11px; padding: 2px 12px; font-weight: 600; }}
QLabel#badgePink {{ background: #F8E0F0; color: #B93A85; border-radius: 11px; padding: 2px 12px; font-weight: 600; }}
QLabel#badgeWarn {{ background: #D0ECF8; color: #0077B6; border-radius: 11px; padding: 2px 12px; font-weight: 600; }}
QLabel#hint {{ color: #5C8DB8; }}
QLabel#warning {{ color: #0077B6; font-weight: 600; }}
QComboBox {{ background: white; border: 1px solid #C0D8EC; border-radius: 10px; padding: 6px 10px; }}
QComboBox::drop-down {{ border: none; width: 22px; }}
QComboBox QAbstractItemView {{
background: white; border: 1px solid #C0D8EC; border-radius: 10px;
selection-background-color: {ACCENT}; selection-color: white; outline: none;
}}
_FONT = '"Segoe UI Variable Text", "Segoe UI", "Inter", "Helvetica Neue", "Yu Gothic UI", "Meiryo", sans-serif'
_MONO = '"Cascadia Code", "JetBrains Mono", "Consolas", "SF Mono", monospace'
QScrollBar:vertical {{ background: transparent; width: 11px; margin: 2px; }}
QScrollBar::handle:vertical {{ background: #C0D8EC; border-radius: 5px; min-height: 28px; }}
QScrollBar::handle:vertical:hover {{ background: {ACCENT}; }}
QScrollBar:horizontal {{ background: transparent; height: 11px; margin: 2px; }}
QScrollBar::handle:horizontal {{ background: #C0D8EC; border-radius: 5px; min-width: 28px; }}
QScrollBar::handle:horizontal:hover {{ background: {ACCENT}; }}
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; width: 0; border: none; background: none; }}
QCheckBox {{ spacing: 8px; }}
QCheckBox::indicator, QRadioButton::indicator {{
width: 16px; height: 16px; background: white; border: 1px solid #C0D8EC; border-radius: 5px;
}}
QRadioButton::indicator {{ border-radius: 9px; }}
QCheckBox::indicator:hover, QRadioButton::indicator:hover {{ border-color: {ACCENT}; }}
QCheckBox::indicator:checked, QRadioButton::indicator:checked {{
background: {GRADIENT}; border-color: {ACCENT};
}}
QCheckBox::indicator:disabled {{ border-color: #D0E8F5; background: #F0F8FC; }}
QCheckBox::indicator:checked:disabled, QRadioButton::indicator:checked:disabled {{
background: #B8D4E8; border-color: #8AB8D8;
}}
DARK = Palette(
name="dark",
# ---- VS Code "Dark Modern" ----------------------------------------------
# Values taken from the shipped theme JSON. Where VS Code's own choice falls
# below WCAG AA it is nudged just far enough to pass; each such value carries
# a note with VS Code's original and the measured ratio.
bg="#1F1F1F", # editor.background
surface="#252526", # panel / card
surface_raised="#313131", # input.background
overlay="#252526", # menus, tooltips
sunken="#181818", # logs, terminals — below the ramp
hover="#2A2D2E", # list.hoverBackground
active="#37373D", # list.inactiveSelectionBackground
# The sidebar is DARKER than the editor — that is the VS Code silhouette.
nav_bg="#181818", # sideBar.background
nav_border="#2B2B2B", # sideBar.border
nav_hover="#2A2D2E",
nav_selected="#04395E", # list.activeSelectionBackground
border="#2B2B2B", # panel.border
border_strong="#3C3C3C", # input.border
focus_ring="#0078D4", # focusBorder
text="#CCCCCC", # editor.foreground
text_muted="#9D9D9D", # descriptionForeground
text_faint="#9A9A9A", # lifted: #8B8B8B was 3.82:1 on input surfaces
text_disabled="#5A5A5A",
on_accent="#FFFFFF",
accent="#4DAAFC", # textLink.foreground — accent as TEXT
accent_solid="#0078D4", # button.background — accent as FILL
accent_solid_hover="#026EC1",
accent_solid_active="#005FB8",
accent_soft="rgba(0,120,212,0.22)",
accent_soft_hover="rgba(0,120,212,0.32)",
accent_wash="#12283C", # pre-blended: Qt rich text ignores alpha
success="#89D185", # gitDecoration added
success_soft="rgba(137,209,133,0.16)",
warning="#CCA700", # editorWarning
warning_soft="rgba(204,167,0,0.16)",
danger="#F76464", # editorError #F14C4C lifted (4.29:1 on panels)
danger_solid="#C4302B",
danger_solid_hover="#D9433C",
danger_soft="rgba(241,76,76,0.16)",
info="#4DAAFC",
info_soft="rgba(77,170,252,0.16)",
purple="#C586C0", # Dark+ syntax purple
purple_soft="rgba(197,134,192,0.16)",
pink="#D16D9E",
pink_soft="rgba(209,109,158,0.16)",
selection_bg="#264F78", # editor.selectionBackground
selection_fg="#FFFFFF",
scroll_handle="#4E4E4E", # scrollbarSlider
scroll_handle_hover="#5A5A5A",
code_bg="#1F1F1F",
code_fg="#CCCCCC",
code_gutter_bg="#1F1F1F",
# VS Code uses #6E7681 for line numbers — only 3.59:1. Lifted to clear AA.
code_gutter_fg="#858D97",
code_selection="#264F78",
code_comment="#6A9955", # ---- Dark+ syntax, unchanged --------------
code_keyword="#569CD6",
code_type="#4EC9B0",
code_func="#DCDCAA",
code_attr="#9CDCFE",
code_string="#CE9178",
code_number="#B5CEA8",
code_error="#F44747",
diff_add_bg="#1B3A1B", # diffEditor inserted, pre-blended
diff_add_fg="#89D185",
diff_del_bg="#4B1818", # diffEditor removed, pre-blended
diff_del_fg="#F76464",
chart_grid="#2B2B2B",
chart_label="#9D9D9D",
role_user="#4DAAFC",
role_assistant="#4EC9B0",
role_tool="#C586C0",
role_result="#89D185",
role_error="#F14C4C",
radius_sm=3, # VS Code is squarer than the previous look
radius=4,
radius_lg=6,
font_family=_FONT,
font_size=13,
font_mono=_MONO,
)
QMenu {{ background: white; color: #1A2332; border: 1px solid #C0D8EC; border-radius: 10px; padding: 4px; }}
QMenu::item {{ padding: 6px 16px; border-radius: 6px; }}
QMenu::item:selected {{ background: {ACCENT}; color: white; }}
QToolTip {{ background: #FFFFFF; color: #1A2332; border: 1px solid #C0D8EC; border-radius: 6px; padding: 4px 8px; }}
"""
LIGHT = Palette(
name="light",
# ---- VS Code "Light Modern" ---------------------------------------------
bg="#FFFFFF", # editor.background
surface="#F8F8F8", # sideBar / panel
surface_raised="#FFFFFF", # input.background
overlay="#FFFFFF",
sunken="#F3F3F3",
hover="#F2F2F2", # list.hoverBackground
active="#E8E8E8", # list.activeSelectionBackground
nav_bg="#F8F8F8", # sideBar.background
nav_border="#E5E5E5", # sideBar.border
nav_hover="#F2F2F2",
nav_selected="#E4E6F1", # active row, tinted toward the accent
border="#E5E5E5",
border_strong="#CECECE", # input.border
focus_ring="#005FB8", # focusBorder
text="#3B3B3B", # editor.foreground
text_muted="#616161",
# VS Code uses #767676 — 4.28:1 on the sidebar. Darkened to clear AA there.
text_faint="#6E6E6E",
text_disabled="#A0A0A0",
on_accent="#FFFFFF",
accent="#005FB8", # textLink / button
accent_solid="#005FB8",
accent_solid_hover="#0258A8",
accent_solid_active="#004C97",
accent_soft="rgba(0,95,184,0.10)",
accent_soft_hover="rgba(0,95,184,0.16)",
accent_wash="#E6EEF8",
# VS Code green #388A34 is 4.33:1 and amber #BF8803 only 3.12:1 — both lifted.
success="#317A2D",
success_soft="#DFF3DE",
warning="#8F6500", # VS Code #BF8803 = 3.12:1
warning_soft="#FBF0D0",
danger="#CD3131", # editorError
danger_solid="#CD3131",
danger_solid_hover="#B82A2A",
danger_soft="#FDEFEF", # lightened so #CD3131 clears AA on it
info="#005FB8",
info_soft="#DDEBF9",
purple="#6F42C1",
purple_soft="#EDE7FA",
pink="#B3247E",
pink_soft="#FAE3F0",
selection_bg="#ADD6FF", # editor.selectionBackground
selection_fg="#000000",
scroll_handle="#C1C1C1",
scroll_handle_hover="#A6A6A6",
code_bg="#FFFFFF",
code_fg="#3B3B3B",
code_gutter_bg="#F8F8F8",
code_gutter_fg="#656C76", # VS Code #6E7681 = 4.33:1 on the gutter
code_selection="#ADD6FF",
code_comment="#008000", # ---- Light+ syntax ------------------------
code_keyword="#0000FF",
code_type="#267F99",
code_func="#795E26",
code_attr="#E50000",
code_string="#A31515",
code_number="#098658",
code_error="#CD3131",
diff_add_bg="#DBF4DB",
diff_add_fg="#1E6F1A",
diff_del_bg="#FBE3E3",
diff_del_fg="#B82A2A",
chart_grid="#E5E5E5",
chart_label="#616161",
role_user="#005FB8",
role_assistant="#267F99",
role_tool="#6F42C1",
role_result="#317A2D",
role_error="#CD3131",
radius_sm=3,
radius=4,
radius_lg=6,
font_family=_FONT,
font_size=13,
font_mono=_MONO,
)
# Node colors used by the Graph view (shared light/dark).
NODE_COLORS = {
"user": "#3B82F6",
"assistant": ACCENT,
"tool": "#8B5CF6",
"result": "#22A06B",
"error": "#E5484D",
_PALETTES = {"dark": DARK, "light": LIGHT}
# ---------------------------------------------------------------------------
# The one style sheet. `$token` placeholders are filled from the Palette above;
# use `${token}px` where a unit follows the name.
#
# Read it as a cascade: reset -> shell -> surfaces -> controls -> chrome.
# ---------------------------------------------------------------------------
_TEMPLATE = Template("""
/* ---- reset ------------------------------------------------------------ */
* { font-family: $font_family; font-size: ${font_size}px; }
QWidget { background: $bg; color: $text; }
QMainWindow::separator { background: $border; width: 1px; height: 1px; }
QSplitter::handle { background: $border; }
QSplitter::handle:horizontal { width: 1px; }
QSplitter::handle:vertical { height: 1px; }
QSplitter::handle:hover { background: $border_strong; }
QStatusBar { background: $bg; color: $text_faint; border-top: 1px solid $border; }
QToolTip {
background: $overlay; color: $text; border: 1px solid $border_strong;
border-radius: ${radius}px; padding: 5px 9px;
}
/* Icons are drawn at text scale, not as decoration. */
QPushButton, QToolButton, QComboBox, QTabBar { qproperty-iconSize: 15px 15px; }
QTreeWidget#navrail { qproperty-iconSize: 16px 16px; }
/* ---- shell ------------------------------------------------------------ */
QWidget#topbar { background: $bg; border: none; border-bottom: 1px solid $border; }
QLabel#brand { color: $text; font-size: 15px; font-weight: 700; background: transparent; }
QWidget#navWrap { background: $nav_bg; border-right: 1px solid $nav_border; }
QWidget#navWrap QTreeWidget, QWidget#navWrap QListWidget { background: transparent; border: none; }
QWidget#contentArea { background: $bg; }
/* Rail rows sit on nav_bg, so they need their own hover/selected steps — the
generic ones are tuned against `bg` and wash out here. The active item also
carries a 2px accent marker, so which section you are in survives even at a
glance or for anyone who cannot separate the two greys. */
QWidget#navWrap QTreeWidget::item, QWidget#navWrap QListWidget::item {
padding: 6px 4px; border-radius: ${radius}px;
}
QWidget#navWrap QTreeWidget::item:hover, QWidget#navWrap QListWidget::item:hover {
background: $nav_hover;
}
QWidget#navWrap QTreeWidget::item:selected, QWidget#navWrap QListWidget::item:selected {
background: $nav_selected; color: $text;
border-left: 2px solid $accent; font-weight: 600;
}
/* ---- surfaces --------------------------------------------------------- */
QGroupBox {
background: $surface; border: 1px solid $border; border-radius: ${radius_lg}px;
margin-top: 14px; padding: 16px 12px 12px 12px; font-weight: 600;
}
QGroupBox::title {
subcontrol-origin: margin; subcontrol-position: top left; left: 12px; top: 1px;
padding: 0 6px; color: $text_muted; font-size: 12px; font-weight: 600;
}
QFrame[frameShape="4"], QFrame[frameShape="5"] { background: $border; border: none; }
QScrollArea { background: transparent; border: none; }
QAbstractScrollArea::corner { background: transparent; }
/* ---- tabs: an underline, not a pill. -------------------------------------
The old pill tabs read as buttons and fought the real buttons for
attention. A 2px rule under the active label is quieter and unambiguous. */
QTabWidget::pane { background: $bg; border: none; border-top: 1px solid $border; top: -1px; }
QTabBar { background: transparent; qproperty-drawBase: 0; }
QTabBar::tab {
background: transparent; color: $text_muted; padding: 9px 14px; margin: 0 2px 0 0;
border: none; border-bottom: 2px solid transparent; font-weight: 500;
}
QTabBar::tab:selected { color: $text; border-bottom: 2px solid $accent; font-weight: 600; }
QTabBar::tab:hover:!selected { color: $text; background: $hover; }
/* Co4E flow strip — browser-style tabs, so these stay enclosed. */
QTabBar#flowTabs::tab {
background: $surface; color: $text_muted; border: 1px solid $border;
border-radius: ${radius}px; padding: 6px 10px; margin: 0 3px 0 0; min-height: 22px;
}
QTabBar#flowTabs::tab:selected { background: $surface_raised; color: $text; border-color: $border_strong; }
QTabBar#flowTabs::tab:hover:!selected { background: $hover; color: $text; }
QPushButton#flowAddBtn {
background: transparent; color: $text_muted; border: 1px solid $border;
border-radius: ${radius}px; padding: 6px 0; font-size: 15px; min-height: 22px;
}
QPushButton#flowAddBtn:hover { background: $hover; color: $text; }
/* Co4E icon sidebar — no chrome until it is the active one. */
QTabBar#co4eSideTabs { qproperty-iconSize: 18px 18px; }
QTabBar#co4eSideTabs::tab {
background: transparent; color: $text_muted; border: none;
border-radius: ${radius}px; padding: 6px; margin: 0 4px 0 0;
}
QTabBar#co4eSideTabs::tab:selected { background: $accent_soft; color: $text; }
QTabBar#co4eSideTabs::tab:hover:!selected { background: $hover; color: $text; }
QGraphicsView#co4eCanvas {
background: $surface; border: 1px solid $border; border-radius: ${radius_lg}px;
}
/* ---- text entry & item views ------------------------------------------ */
QPlainTextEdit, QTextEdit, QTextBrowser, QLineEdit, QSpinBox, QDoubleSpinBox,
QTreeView, QListView, QTableView, QTreeWidget, QListWidget, QTableWidget {
background: $surface_raised; color: $text; border: 1px solid $border;
border-radius: ${radius}px; selection-background-color: $selection_bg;
selection-color: $selection_fg;
}
QPlainTextEdit, QTextEdit, QLineEdit, QSpinBox, QDoubleSpinBox { padding: 6px 8px; }
QPlainTextEdit:hover, QTextEdit:hover, QLineEdit:hover { border-color: $border_strong; }
QPlainTextEdit:focus, QTextEdit:focus, QLineEdit:focus,
QSpinBox:focus, QDoubleSpinBox:focus { border: 1px solid $focus_ring; }
QLineEdit:disabled, QPlainTextEdit:disabled, QTextEdit:disabled {
background: $surface; color: $text_disabled;
}
QTreeView::item, QListView::item, QTableView::item { padding: 4px 3px; border-radius: ${radius_sm}px; }
QTreeView::item:hover, QListView::item:hover { background: $hover; }
QTreeView::item:selected, QListView::item:selected, QTableView::item:selected {
background: $accent_soft; color: $text;
}
QHeaderView::section {
background: $bg; color: $text_muted; border: none;
border-bottom: 1px solid $border; padding: 7px 6px; font-weight: 600;
}
/* ---- buttons -----------------------------------------------------------
Default is a quiet outline. Weight is reserved for #primary / #danger, so
at most one button per view should carry a fill. */
QPushButton {
background: $surface_raised; color: $text; border: 1px solid $border_strong;
border-radius: ${radius}px; padding: 7px 14px; font-weight: 500;
}
QPushButton:hover { background: $hover; border-color: $border_strong; }
QPushButton:pressed { background: $active; }
QPushButton:disabled { color: $text_disabled; background: $surface; border-color: $border; }
QPushButton:focus { border: 1px solid $focus_ring; }
QPushButton#primary {
background: $accent_solid; color: $on_accent; border: 1px solid transparent; font-weight: 600;
}
QPushButton#primary:hover { background: $accent_solid_hover; }
QPushButton#primary:pressed { background: $accent_solid_active; }
QPushButton#primary:disabled { background: $surface; color: $text_disabled; border-color: $border; }
QPushButton#danger {
background: $danger_solid; color: $on_accent; border: 1px solid transparent; font-weight: 600;
}
QPushButton#danger:hover { background: $danger_solid_hover; }
QPushButton#danger:disabled { background: $surface; color: $text_disabled; border-color: $border; }
/* Ghost buttons: nav section headers and icon-only chrome. */
QPushButton#navMenuBtn {
background: transparent; border: none; border-radius: ${radius}px; padding: 5px 6px;
font-weight: 600; font-size: 11px; letter-spacing: 0.6px; color: $text_faint; text-align: left;
}
QPushButton#navMenuBtn:hover { background: $hover; color: $text; }
QPushButton#navMenuBtn:pressed { background: $active; }
QToolButton {
background: transparent; color: $text_muted; border: none;
border-radius: ${radius}px; padding: 5px;
}
QToolButton:hover { background: $hover; color: $text; }
QToolButton:pressed { background: $active; }
QToolButton::menu-indicator { image: none; }
/* ---- pickers ----------------------------------------------------------- */
QComboBox {
background: $surface_raised; color: $text; border: 1px solid $border_strong;
border-radius: ${radius}px; padding: 6px 10px;
}
QComboBox:hover { background: $hover; }
QComboBox:focus { border-color: $focus_ring; }
QComboBox:disabled { color: $text_disabled; background: $surface; border-color: $border; }
QComboBox::drop-down { border: none; width: 20px; }
QComboBox QAbstractItemView {
background: $overlay; color: $text; border: 1px solid $border_strong;
border-radius: ${radius}px; padding: 4px; outline: none;
selection-background-color: $accent_soft; selection-color: $text;
}
QMenu { background: $overlay; color: $text; border: 1px solid $border_strong;
border-radius: ${radius}px; padding: 4px; }
QMenu::item { padding: 6px 14px; border-radius: ${radius_sm}px; }
QMenu::item:selected { background: $accent_soft; color: $text; }
QMenu::item:disabled { color: $text_disabled; }
QMenu::separator { height: 1px; background: $border; margin: 4px 6px; }
QMenuBar { background: $bg; color: $text; border-bottom: 1px solid $border; }
QMenuBar::item { padding: 5px 10px; border-radius: ${radius_sm}px; background: transparent; }
QMenuBar::item:selected { background: $hover; }
/* ---- toggles ----------------------------------------------------------- */
QCheckBox, QRadioButton { spacing: 8px; background: transparent; }
QCheckBox::indicator, QRadioButton::indicator {
width: 16px; height: 16px; background: $surface_raised;
border: 1px solid $border_strong; border-radius: ${radius_sm}px;
}
QRadioButton::indicator { border-radius: 9px; }
QCheckBox::indicator:hover, QRadioButton::indicator:hover { border-color: $accent; }
QCheckBox::indicator:checked, QRadioButton::indicator:checked {
background: $accent_solid; border-color: $accent_solid;
}
QCheckBox::indicator:disabled, QRadioButton::indicator:disabled {
background: $surface; border-color: $border;
}
QCheckBox::indicator:checked:disabled, QRadioButton::indicator:checked:disabled {
background: $border_strong; border-color: $border_strong;
}
QSlider::groove:horizontal { height: 4px; background: $border; border-radius: 2px; }
QSlider::sub-page:horizontal { background: $accent_solid; border-radius: 2px; }
QSlider::handle:horizontal {
width: 14px; height: 14px; margin: -6px 0; border-radius: 7px;
background: $surface_raised; border: 1px solid $border_strong;
}
QSlider::handle:horizontal:hover { border-color: $accent; }
QProgressBar {
background: $surface; border: none; border-radius: 3px;
height: 6px; text-align: center; color: $text_muted;
}
QProgressBar::chunk { background: $accent_solid; border-radius: 3px; }
/* ---- scrollbars: overlay-thin, no arrows. ------------------------------ */
QScrollBar:vertical { background: transparent; width: 10px; margin: 2px; }
QScrollBar::handle:vertical { background: $scroll_handle; border-radius: 4px; min-height: 28px; }
QScrollBar::handle:vertical:hover { background: $scroll_handle_hover; }
QScrollBar:horizontal { background: transparent; height: 10px; margin: 2px; }
QScrollBar::handle:horizontal { background: $scroll_handle; border-radius: 4px; min-width: 28px; }
QScrollBar::handle:horizontal:hover { background: $scroll_handle_hover; }
QScrollBar::add-line, QScrollBar::sub-line { height: 0; width: 0; border: none; background: none; }
QScrollBar::add-page, QScrollBar::sub-page { background: none; }
/* ---- badges & inline text tones ---------------------------------------
One shape, six tones. Pick by meaning: badgeSuccess for a finished run,
badgeDanger for a failed one — not by which colour looks nice. */
QLabel#badge, QLabel#badgeSuccess, QLabel#badgeWarn, QLabel#badgeDanger,
QLabel#badgePurple, QLabel#badgePink {
border-radius: ${radius_sm}px; padding: 2px 8px; font-size: 12px; font-weight: 600;
}
QLabel#badge { background: $info_soft; color: $info; }
QLabel#badgeSuccess { background: $success_soft; color: $success; }
QLabel#badgeWarn { background: $warning_soft; color: $warning; }
QLabel#badgeDanger { background: $danger_soft; color: $danger; }
QLabel#badgePurple { background: $purple_soft; color: $purple; }
QLabel#badgePink { background: $pink_soft; color: $pink; }
QLabel { background: transparent; }
QLabel#hint { color: $text_muted; }
QLabel#faint { color: $text_faint; }
QLabel#warning { color: $warning; font-weight: 600; }
QLabel#error { color: $danger; font-weight: 600; }
QLabel#success { color: $success; font-weight: 600; }
QLabel#sectionTitle { color: $text; font-size: 15px; font-weight: 600; }
/* ---- code, terminals & logs -------------------------------------------
These read as "sunken" surfaces: the eye goes in, not across. */
QPlainTextEdit#codeEditor, QPlainTextEdit#termOutput, QPlainTextEdit#logView {
background: $code_bg; color: $code_fg; border: none;
font-family: $font_mono; selection-background-color: $code_selection;
}
QLineEdit#termInput {
background: $code_bg; color: $code_fg; border: none; border-top: 1px solid $border;
font-family: $font_mono; border-radius: 0; padding: 7px 10px;
}
QLineEdit#termInput:focus { border-top-color: $accent; }
/* The help-agent dock styles itself from these same tokens — it is a floating
overlay that re-applies on every theme switch. See ui/help_agent_widget.py. */
""")
def resolve_theme(theme: str) -> str:
"""Resolve 'system' to 'dark'/'light' based on the OS color scheme."""
if theme != "system":
"""Resolve ``'system'`` to ``'dark'``/``'light'`` from the OS colour scheme."""
if theme in _PALETTES:
return theme
try:
from PySide6.QtCore import Qt
@@ -291,5 +613,47 @@ def resolve_theme(theme: str) -> str:
return "dark"
def palette(theme: str) -> Palette:
"""The token set for ``theme``. Painting code reads its colours from here."""
return _PALETTES[resolve_theme(theme)]
# The theme the running app is currently showing. Painting code (paintEvent,
# QSyntaxHighlighter, canvas items) reads it via current_palette() instead of
# re-reading config.json — that used to cost a file open per repaint.
_active_theme = "dark"
def set_active_theme(theme: str) -> str:
"""Record the theme the app just applied. Call this next to every
``QApplication.setStyleSheet(stylesheet(...))``. Returns the resolved name."""
global _active_theme
_active_theme = resolve_theme(theme)
return _active_theme
def current_theme() -> str:
"""The resolved theme ('dark'/'light') the app is showing right now."""
return _active_theme
def current_palette() -> Palette:
"""Tokens for the theme the app is showing right now."""
return _PALETTES[_active_theme]
def stylesheet(theme: str) -> str:
return _LIGHT if resolve_theme(theme) == "light" else _DARK
"""The application-wide Qt style sheet for ``theme``."""
return _TEMPLATE.substitute(asdict(palette(theme)))
def role_colors(theme: str) -> dict[str, str]:
"""Conversation/graph node colours keyed by role."""
p = palette(theme)
return {
"user": p.role_user,
"assistant": p.role_assistant,
"tool": p.role_tool,
"result": p.role_result,
"error": p.role_error,
}
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.")
+2 -2
View File
@@ -28,7 +28,7 @@ from ..core import usage_tracker as ut
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..state import AppContext
from .icons import icon
from .icons import DOT_AMBER, icon
from .widgets import fmt_tokens
_PERIODS = ("day", "week", "month", "year")
@@ -367,7 +367,7 @@ class AccountsTab(QWidget):
label = f"{acc.display_name or acc.username} ({acc.username}) — {tr(f'accounts.role.{acc.role}')}"
item = QTreeWidgetItem([label])
if is_subadmin: # subadmin badge → star icon instead of a ★ glyph
item.setIcon(0, icon("star", color="#f59e0b"))
item.setIcon(0, icon("star", color=DOT_AMBER))
item.setData(0, Qt.UserRole, ("account", acc.username))
if acc.email:
item.setToolTip(0, acc.email)
+13 -11
View File
@@ -20,6 +20,7 @@ from ..core.calendar_grid import (
GRANULARITIES, group_tasks_by_date, month_grid, month_task_counts, shift_period, week_days,
)
from ..i18n import on_language_changed, tr
from ..theme import current_palette
from .icons import icon
_WEEKDAY_KEYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
@@ -56,20 +57,21 @@ class _DayCell(QFrame):
today: bool = False, weekend: bool = False) -> None:
self._date_str = d.isoformat()
self.date_lbl.setText(str(d.day))
num_color = "#0096C7" if today else ("#888" if dim else "")
self.date_lbl.setStyleSheet(f"font-weight:700; color:{num_color};")
# Today = accent border + stronger tint; weekend (Sat/Sun) = a subtle
# darker-blue tint than the base cell. rgba overlays read correctly on
# both light and dark themes.
base_border = "1px solid rgba(128,128,128,0.35)"
p = current_palette()
num_color = p.accent if today else (p.text_faint if dim else p.text)
self.date_lbl.setStyleSheet(f"font-weight:600; color:{num_color};")
# Today is the only cell that gets a filled surface + accent border;
# weekends are set apart by a recessed surface alone, so the eye lands
# on "today" first and on the weekend block only when scanning.
r = p.radius
if today:
css = ("#dayCell { background: rgba(0,150,199,0.22); "
"border: 2px solid #0096C7; border-radius: 6px; }")
css = (f"#dayCell {{ background: {p.accent_soft}; "
f"border: 1px solid {p.accent}; border-radius: {r}px; }}")
elif weekend:
css = ("#dayCell { background: rgba(0,120,182,0.13); "
f"border: {base_border}; border-radius: 6px; }}")
css = (f"#dayCell {{ background: {p.surface}; "
f"border: 1px solid {p.border}; border-radius: {r}px; }}")
else:
css = f"#dayCell {{ border: {base_border}; border-radius: 6px; }}"
css = f"#dayCell {{ border: 1px solid {p.border}; border-radius: {r}px; }}"
self.setStyleSheet(css)
self.list.clear()
for t in tasks:
+2 -1
View File
@@ -24,6 +24,7 @@ from PySide6.QtWidgets import (
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..state import AppContext
from ..theme import current_palette
from .chat_view import ChatView, ThinkingIndicator
from .composer import Composer
from .icons import collapse_right_icon, icon as app_icon
@@ -140,7 +141,7 @@ class ChatPanel(QWidget):
# updated after each turn; cost uses the Monitoring model-price table.
self._usage_total_lbl = QLabel("")
self._usage_total_lbl.setObjectName("hint")
self._usage_total_lbl.setStyleSheet("color: rgba(140,146,152,0.9);")
self._usage_total_lbl.setStyleSheet(f"color: {current_palette().text_faint};")
self.composer.add_bottom_left(self._usage_total_lbl)
# Per-tab "Agent" = which MODEL this tab uses (e.g. deepseek / gemma /
+50 -53
View File
@@ -12,7 +12,7 @@ from PySide6.QtWidgets import (
)
from ..i18n import on_language_changed, tr
from ..theme import ACCENT, resolve_theme
from ..theme import palette, resolve_theme
from ..config import CONFIG_DIR
from .osutil import is_image, open_folder, open_path
@@ -28,11 +28,18 @@ def _app_theme() -> str:
return "dark"
# Timeline dot color per role (reads on both themes — small, saturated).
_DOT = {
"user": "#48CAE4", "assistant": "#48D9A0", "tool": "#9B8FF7",
"error": "#E5484D", "success": "#48D9A0",
}
def _p():
"""Design tokens for the theme in effect right now."""
return palette(_app_theme())
def _dot_color(role: str) -> str:
"""Timeline dot colour for a message role."""
p = _p()
return {
"user": p.role_user, "assistant": p.role_assistant, "tool": p.role_tool,
"error": p.role_error, "success": p.role_result,
}.get(role, p.text_faint)
class _TimelineGutter(QWidget):
@@ -52,17 +59,17 @@ class _TimelineGutter(QWidget):
def paintEvent(self, _e): # noqa: N802
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
dark = _app_theme() == "dark"
tok = _p()
x = 11.0
cy = 15.0
# connector line (faint) running the full height → continuous rail
p.setPen(QPen(QColor("#243a56" if dark else "#CBDDEC"), 2))
p.setPen(QPen(QColor(tok.border), 2))
p.drawLine(int(x), 0, int(x), self.height())
# a background ring lifts the dot off the line
p.setPen(Qt.NoPen)
p.setBrush(QColor("#0A1628" if dark else "#E8F4FD"))
p.setBrush(QColor(tok.bg))
p.drawEllipse(QPointF(x, cy), 7.5, 7.5)
p.setBrush(QColor(_DOT.get(self._role, "#8FB2D4")))
p.setBrush(QColor(_dot_color(self._role)))
p.drawEllipse(QPointF(x, cy), 4.5, 4.5)
@@ -72,18 +79,20 @@ def _diff_legend(diff_text: str) -> str:
so the before/after distinction is explicit, not just implied by color."""
has_add = any(ln.startswith("+") and not ln.startswith("+++") for ln in diff_text.splitlines())
has_del = any(ln.startswith("-") and not ln.startswith("---") for ln in diff_text.splitlines())
before = (f'<span style="background:#3a1620; color:#ff9aa8; padding:1px 8px; '
f'border-radius:4px; font-weight:600;">{html.escape(tr("chat.diff_before"))}</span>')
after = (f'<span style="background:#0d3321; color:#7ee2a8; padding:1px 8px; '
f'border-radius:4px; font-weight:600;">{html.escape(tr("chat.diff_after"))}</span>')
p = _p()
def pill(bg: str, fg: str, key: str) -> str:
return (f'<span style="background:{bg}; color:{fg}; padding:1px 8px; '
f'border-radius:4px; font-weight:600;">{html.escape(tr(key))}</span>')
if has_add and has_del:
badge = f'{before}<span style="color:#8b8d98;"> → </span>{after}'
badge = (pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_before")
+ f'<span style="color:{p.text_muted};"> → </span>'
+ pill(p.diff_add_bg, p.diff_add_fg, "chat.diff_after"))
elif has_add:
badge = (f'<span style="background:#0d3321; color:#7ee2a8; padding:1px 8px; '
f'border-radius:4px; font-weight:600;">{html.escape(tr("chat.diff_added"))}</span>')
badge = pill(p.diff_add_bg, p.diff_add_fg, "chat.diff_added")
elif has_del:
badge = (f'<span style="background:#3a1620; color:#ff9aa8; padding:1px 8px; '
f'border-radius:4px; font-weight:600;">{html.escape(tr("chat.diff_removed"))}</span>')
badge = pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_removed")
else:
return ""
return f'<div style="margin-bottom:6px;">{badge}</div>'
@@ -97,21 +106,22 @@ def diff_to_html(diff_text: str) -> str:
empty 'before') naturally renders as all-green, which is exactly what
``difflib.unified_diff`` already produces for it."""
legend = _diff_legend(diff_text)
p = _p()
rows = []
for ln in diff_text.splitlines():
esc = html.escape(ln) if ln else "&nbsp;"
if ln.startswith(("+++", "---")):
rows.append(f'<div style="color:#8b8d98;">{esc}</div>')
rows.append(f'<div style="color:{p.text_muted};">{esc}</div>')
elif ln.startswith("@@"):
rows.append(f'<div style="color:#7c8aff;">{esc}</div>')
rows.append(f'<div style="color:{p.accent};">{esc}</div>')
elif ln.startswith("+"):
rows.append(f'<div style="background:#0d3321; color:#7ee2a8;">{esc}</div>')
rows.append(f'<div style="background:{p.diff_add_bg}; color:{p.diff_add_fg};">{esc}</div>')
elif ln.startswith("-"):
rows.append(f'<div style="background:#3a1620; color:#ff9aa8;">{esc}</div>')
rows.append(f'<div style="background:{p.diff_del_bg}; color:{p.diff_del_fg};">{esc}</div>')
else:
rows.append(f"<div>{esc}</div>")
body = "".join(rows) or "(no textual change)"
return (f'{legend}<div style="font-family:Consolas,\'Courier New\',monospace; font-size:12.5px; '
return (f'{legend}<div style="font-family:{p.font_mono}; font-size:12.5px; '
f'white-space:pre-wrap;">{body}</div>')
@@ -219,12 +229,12 @@ class MessageBubble(QFrame):
self._head.setCursor(Qt.PointingHandCursor)
self._head.setStyleSheet(
"QPushButton { text-align:left; border:none; background:transparent;"
" font-weight:600; color:#8b8d98; padding:0; }")
f" font-weight:600; color:{_p().text_muted}; padding:0; }}")
self._head.clicked.connect(self._toggle_body)
lay.addWidget(self._head)
else:
head = QLabel(title)
head.setStyleSheet("font-weight:600; color:#8b8d98;")
head.setStyleSheet(f"font-weight:600; color:{_p().text_muted};")
lay.addWidget(head)
self.body = QTextBrowser()
@@ -265,36 +275,23 @@ class MessageBubble(QFrame):
def _apply_theme_styles(self, role: str) -> None:
"""Apply text color to the body QTextBrowser based on current theme + role."""
theme = self._current_theme()
if theme == "light":
if role == "success":
text_color = "#1B7A3D"
elif role == "error":
text_color = "#C0392B"
elif role in ("tool",):
text_color = "#5C6B7A" # muted (secondary) like Claude's steps
else:
text_color = "#1A2332"
else:
if role == "success":
text_color = "#7ee2a8"
elif role == "error":
text_color = "#ff9aa8"
elif role in ("tool",):
text_color = "#9aa6b8"
else:
text_color = "#eceef2"
p = _p()
text_color = {
"success": p.success,
"error": p.danger,
"tool": p.text_muted, # secondary, like Claude's steps
}.get(role, p.text)
self.body.setStyleSheet(f"background: transparent; border: none; color: {text_color};")
def _apply_style(self, role: str) -> None:
"""Flat timeline row — no bubble box; the left dot/rail conveys role and
structure (Claude-Code style). The user's own message gets a faint tint
so questions are easy to pick out when scanning."""
theme = self._current_theme()
p = _p()
if role == "user":
tint = "rgba(72,202,228,0.10)" if theme == "dark" else "rgba(72,202,228,0.14)"
self.setStyleSheet(
f"QFrame {{ background: {tint}; border: none; border-radius: 10px; }}")
f"QFrame {{ background: {p.surface}; border: none; "
f"border-radius: {p.radius}px; }}")
else:
self.setStyleSheet("QFrame { background: transparent; border: none; }")
@@ -353,20 +350,20 @@ class MessageBubble(QFrame):
existing.setText(text)
return
lbl = QLabel(text)
lbl.setObjectName("hint")
lbl.setStyleSheet("color: rgba(140,146,152,0.9); font-size: 11px;")
lbl.setObjectName("faint")
lbl.setStyleSheet(f"color: {_p().text_faint}; font-size: 11px;")
self._usage_lbl = lbl
self._content_layout.addWidget(lbl)
def add_delete_link(self, callback) -> None:
link = QLabel(f'<a href="#del" style="color:#ef6368;">{tr("chat.delete_link")}</a>')
link = QLabel(f'<a href="#del" style="color:{_p().danger};">{tr("chat.delete_link")}</a>')
link.setToolTip(tr("chat.delete_tooltip"))
link.linkActivated.connect(lambda *_: callback())
self._content_layout.addWidget(link)
def add_folder_link(self, folder: str, label: str | None = None) -> None:
label = label or tr("chat.open_workspace")
link = QLabel(f'<a href="#open" style="color:{ACCENT};">{label}</a>')
link = QLabel(f'<a href="#open" style="color:{_p().accent};">{label}</a>')
link.setToolTip(str(folder))
link.linkActivated.connect(lambda *_: open_folder(folder))
self._content_layout.addWidget(link)
@@ -385,7 +382,7 @@ class MessageBubble(QFrame):
thumb.setCursor(Qt.PointingHandCursor)
self._content_layout.addWidget(thumb)
continue
file_link = QLabel(f'<a href="#open" style="color:{ACCENT};">{name}</a>')
file_link = QLabel(f'<a href="#open" style="color:{_p().accent};">{name}</a>')
file_link.setToolTip(path)
file_link.linkActivated.connect(lambda *_a, fp=path: open_path(fp))
self._content_layout.addWidget(file_link)
+33 -19
View File
@@ -27,13 +27,20 @@ from ..core.co4e import (
STEP_DONE, STEP_ERROR, STEP_PLANNED, STEP_RUNNING, Edge, Node, Step,
compute_waves, new_edge_id, new_node_id,
)
from ..theme import current_palette
def _status_color(status: str) -> str:
"""Accent colour for a step's run status. Resolved per paint so the canvas
follows a live theme switch."""
p = current_palette()
return {
"idle": p.text_muted, STEP_RUNNING: p.accent, STEP_DONE: p.success,
STEP_ERROR: p.danger, STEP_PLANNED: p.purple, "pending": p.text_faint,
}.get(status, p.text_muted)
CO4E_MIME = "application/x-co4e-step"
_STATUS_COLOR = {
"idle": "#5C8DB8", STEP_RUNNING: "#48CAE4", STEP_DONE: "#48D9A0",
STEP_ERROR: "#E5484D", STEP_PLANNED: "#9B8FF7", "pending": "#7A8DA8",
}
_NODE_W, _NODE_H = 210, 96
_PORT_R = 6 # output port radius (the drag-to-connect handle)
_PORT_HIT = 15 # click tolerance around a port
@@ -63,24 +70,29 @@ class _NodeItem(QGraphicsObject):
return QRectF(1, 1, _NODE_W - 2, _NODE_H - 2)
def paint(self, p, _opt, _widget=None):
tok = current_palette()
step = self.node.data
accent = QColor(_STATUS_COLOR.get(self.status, "#5C8DB8"))
body = QColor("#0D1F35")
border = QColor("#48CAE4") if self.isSelected() else QColor("#1A2D4A")
accent = QColor(_status_color(self.status))
body = QColor(tok.surface_raised)
border = QColor(tok.accent) if self.isSelected() else QColor(tok.border)
p.setRenderHint(p.RenderHint.Antialiasing)
rect = self._card_rect()
path = QPainterPath()
path.addRoundedRect(rect, 10, 10)
radius = float(tok.radius_lg)
path.addRoundedRect(rect, radius, radius)
p.fillPath(path, QBrush(body))
p.setPen(QPen(border, 2 if self.isSelected() else 1))
p.drawPath(path)
# header stripe
# header stripe — a tint of the status colour, not the status colour
# itself, so the card's own text stays the brightest thing on it.
hdr = QRectF(rect.left(), rect.top(), rect.width(), 26)
hpath = QPainterPath()
hpath.addRoundedRect(hdr, 10, 10)
p.fillPath(hpath, QBrush(accent.darker(160)))
hpath.addRoundedRect(hdr, radius, radius)
stripe = QColor(accent)
stripe.setAlpha(48)
p.fillPath(hpath, QBrush(stripe))
# label
p.setPen(QColor("#E0F0FF"))
p.setPen(QColor(tok.text))
f = p.font(); f.setBold(True); f.setPointSize(9); p.setFont(f)
p.drawText(QRectF(10, 4, _NODE_W - 20, 20), Qt.AlignVCenter | Qt.AlignLeft,
_elide(step.label, 26))
@@ -89,7 +101,7 @@ class _NodeItem(QGraphicsObject):
p.setPen(accent)
p.drawText(QRectF(10, 30, _NODE_W - 20, 16), Qt.AlignLeft, step.role)
# body: instructions preview OR sub-agent chips
p.setPen(QColor("#8FB2D4"))
p.setPen(QColor(tok.text_muted))
if step.is_parallel:
preview = "⇉ " + ", ".join(s.agent for s in step.sub_agents) if step.sub_agents else "⇉ (no sub-agents)"
else:
@@ -97,7 +109,7 @@ class _NodeItem(QGraphicsObject):
p.drawText(QRectF(10, 46, _NODE_W - 20, 30), Qt.TextWordWrap | Qt.AlignTop,
_elide(preview, 66))
# footer: model + skills + status dot
p.setPen(QColor("#5C8DB8"))
p.setPen(QColor(tok.text_faint))
foot = []
if step.model:
foot.append(step.model)
@@ -109,7 +121,7 @@ class _NodeItem(QGraphicsObject):
# ---- ports ---------------------------------------------------------
# input port (top-center): hollow. output port (bottom-center): filled —
# the drag handle you pull to wire an edge to another step.
port_col = QColor("#48CAE4")
port_col = QColor(tok.accent)
# input port (left-center): hollow. output port (right-center): filled —
# the drag handle you pull to wire an edge to the next step (left→right).
p.setBrush(QBrush(body)); p.setPen(QPen(port_col, 1.4))
@@ -298,12 +310,13 @@ class _EdgeItem(QGraphicsPathItem):
self._apply_pen()
def _apply_pen(self):
tok = current_palette()
if self.isSelected():
color, w = QColor("#48CAE4"), 3
color, w = QColor(tok.accent), 3
elif self._hover:
color, w = QColor("#6FA8C8"), 3
color, w = QColor(tok.text_muted), 3
else:
color, w = QColor("#3A5A78"), 2
color, w = QColor(tok.border_strong), 2
self.setPen(QPen(color, w, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
def update_path(self, points):
@@ -491,7 +504,8 @@ class Co4ECanvas(QGraphicsView):
self._port_src_pt = scene_pt
self._temp_edge = QGraphicsPathItem()
self._temp_edge.setZValue(3.5) # above nodes + edges while connecting
self._temp_edge.setPen(QPen(QColor("#48CAE4"), 2, Qt.DashLine, Qt.RoundCap))
self._temp_edge.setPen(
QPen(QColor(current_palette().accent), 2, Qt.DashLine, Qt.RoundCap))
self._scene.addItem(self._temp_edge)
def update_port_drag(self, scene_pt: QPointF) -> None:
+12 -8
View File
@@ -34,6 +34,7 @@ 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 CO4E_MIME, Co4ECanvas
from .co4e_config_panel import StepConfigPanel
@@ -564,13 +565,14 @@ class Co4ETab(QWidget):
# 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 {"
" border: none; background: transparent; color: #8FB2D4;"
f" border: none; background: transparent; color: {_fp.text_muted};"
" font-size: 13px; font-weight: bold; padding: 0; margin: 0;"
" border-radius: 8px; }"
f" border-radius: {_fp.radius_sm}px; }}"
"QPushButton#flowTabClose:hover {"
" background: rgba(229,72,77,0.18); color: #E5484D; }")
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)
@@ -606,7 +608,7 @@ class Co4ETab(QWidget):
"QScrollArea#flowTabScroll { background: transparent; border: none; }"
"QScrollArea#flowTabScroll QScrollBar:horizontal { height: 8px; background: transparent; margin: 0; }"
"QScrollArea#flowTabScroll QScrollBar::handle:horizontal {"
" background: rgba(143,178,212,0.45); border-radius: 4px; min-width: 30px; }"
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; }")
flow_row = QHBoxLayout()
@@ -861,7 +863,8 @@ class Co4ETab(QWidget):
# $cost) at the bottom, exactly like Cowork's conversation total.
self._usage_total_lbl = QLabel("")
self._usage_total_lbl.setObjectName("hint")
self._usage_total_lbl.setStyleSheet("color: rgba(140,146,152,0.9); font-size: 11px;")
self._usage_total_lbl.setStyleSheet(
f"color: {current_palette().text_faint}; font-size: 11px;")
crow.addWidget(self._usage_total_lbl)
_inp = QWidget(); row = QHBoxLayout(_inp); row.setContentsMargins(0, 0, 0, 0)
self.chat_input = _ChatInput()
@@ -1331,8 +1334,9 @@ class Co4ETab(QWidget):
# Rebuild the always-fresh Runs table from the manager (single source of truth).
if not hasattr(self, "runs_table"):
return
color = {"running": "#48CAE4", "done": "#48D9A0", "error": "#E5484D",
"stopped": "#8FB2D4"}
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).
@@ -1352,7 +1356,7 @@ class Co4ETab(QWidget):
if c == 0:
it.setData(Qt.UserRole, h.id)
if c == 1:
it.setForeground(_qcolor(color.get(h.status, "#E0F0FF")))
it.setForeground(_qcolor(color.get(h.status, p.text)))
t.setItem(r, c, it)
if h.id == sel_id:
sel_row = r
+5 -1
View File
@@ -20,6 +20,7 @@ from PySide6.QtWidgets import (
from ..config import CONFIG_DIR
from ..i18n import on_language_changed, tr
from ..theme import current_palette
from .icons import icon, IconLabel
@@ -583,7 +584,10 @@ class Composer(QWidget):
for p in self._attachments:
item = QListWidgetItem()
row = QWidget()
row.setStyleSheet("background: rgba(140,146,152,0.18); border-radius: 6px;")
_cp = current_palette()
row.setStyleSheet(
f"background: {_cp.surface_raised}; border: 1px solid {_cp.border};"
f" border-radius: {_cp.radius_sm}px;")
h = QHBoxLayout(row)
h.setContentsMargins(8, 2, 4, 2)
h.setSpacing(4)
+5 -1
View File
@@ -24,6 +24,7 @@ from ..core import usage_tracker as ut
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..state import AppContext
from ..theme import current_palette
from .icons import icon
from .spline_chart import SplineChart
from .widgets import BudgetCard as _BudgetCard
@@ -298,8 +299,11 @@ class DashboardTab(QWidget):
n_points = max(1, len(parts))
refs = []
if prev[mi] > 0:
# Muted on purpose: the comparison line is a reference, not the
# series — it must not compete with the accent-coloured spline.
refs.append((prev[mi] / n_points,
f"{tr(ref_key)} {self._delta_txt(cur[mi], prev[mi])}", "#B08968"))
f"{tr(ref_key)} {self._delta_txt(cur[mi], prev[mi])}",
current_palette().text_muted))
self.chart.set_reference_lines(refs)
self.chart.set_data(pts, fmt, tr(f"dashboard.metric_{metric}"))
self._chart_period_lbl.setText(ut.period_range_label(gran, self._chart_offset))
+27 -24
View File
@@ -33,6 +33,7 @@ from PySide6.QtWidgets import (
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..state import AppContext
from ..theme import current_palette
from .chat_view import ChatView
from .icons import icon
from .libreoffice_view import DOC_SUFFIXES
@@ -89,23 +90,26 @@ class PygmentsHighlighter(QSyntaxHighlighter):
from pygments.token import (
Comment, Error, Keyword, Name, Number, Operator, Punctuation, String,
)
p = current_palette()
# Ordered specific → general: first matching token type wins.
# Colours are resolved when the editor is built, so reopening a file
# after a theme switch re-highlights it in the new theme.
return [
(Comment, _fmt("#6A9955", italic=True)),
(Keyword.Type, _fmt("#4EC9B0")),
(Keyword, _fmt("#569CD6")),
(Name.Function, _fmt("#DCDCAA")),
(Name.Class, _fmt("#4EC9B0")),
(Name.Decorator, _fmt("#DCDCAA")),
(Name.Builtin, _fmt("#4EC9B0")),
(Name.Tag, _fmt("#569CD6")),
(Name.Attribute, _fmt("#9CDCFE")),
(String.Doc, _fmt("#6A9955", italic=True)),
(String, _fmt("#CE9178")),
(Number, _fmt("#B5CEA8")),
(Operator, _fmt("#D4D4D4")),
(Punctuation, _fmt("#D4D4D4")),
(Error, _fmt("#F44747")),
(Comment, _fmt(p.code_comment, italic=True)),
(Keyword.Type, _fmt(p.code_type)),
(Keyword, _fmt(p.code_keyword)),
(Name.Function, _fmt(p.code_func)),
(Name.Class, _fmt(p.code_type)),
(Name.Decorator, _fmt(p.code_func)),
(Name.Builtin, _fmt(p.code_type)),
(Name.Tag, _fmt(p.code_keyword)),
(Name.Attribute, _fmt(p.code_attr)),
(String.Doc, _fmt(p.code_comment, italic=True)),
(String, _fmt(p.code_string)),
(Number, _fmt(p.code_number)),
(Operator, _fmt(p.code_fg)),
(Punctuation, _fmt(p.code_fg)),
(Error, _fmt(p.code_error)),
]
def set_filename(self, filename: str, text: str = "") -> None:
@@ -179,9 +183,7 @@ class CodeEditor(QPlainTextEdit):
font.setStyleHint(QFont.Monospace)
font.setPointSize(10)
self.setFont(font)
self.setStyleSheet(
"#codeEditor { background: #1e1e1e; color: #d4d4d4; border: none; "
"selection-background-color: #264f78; }")
# Surface comes from the central style sheet (#codeEditor) — see theme.py.
self._gutter = _LineNumbers(self)
self.blockCountChanged.connect(lambda _=0: self._update_gutter_width())
self.updateRequest.connect(self._on_update_request)
@@ -210,13 +212,14 @@ class CodeEditor(QPlainTextEdit):
self._gutter.setGeometry(QRect(cr.left(), cr.top(), self.line_number_width(), cr.height()))
def paint_line_numbers(self, event) -> None:
p = current_palette()
painter = QPainter(self._gutter)
painter.fillRect(event.rect(), QColor("#1a1a1a"))
painter.fillRect(event.rect(), QColor(p.code_gutter_bg))
block = self.firstVisibleBlock()
num = block.blockNumber()
top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top()
bottom = top + self.blockBoundingRect(block).height()
painter.setPen(QColor("#858585"))
painter.setPen(QColor(p.code_gutter_fg))
while block.isValid() and top <= event.rect().bottom():
if block.isVisible() and bottom >= event.rect().top():
painter.drawText(0, int(top), self._gutter.width() - 6,
@@ -1096,7 +1099,7 @@ class FolderTab(QWidget):
if n and hasattr(self, "_ai_status"):
self._ai_status.setText("⏳ " + tr("folder.ai_status_running")
+ " · " + tr("folder.ai_queue_count", n=n))
self._ai_status.setStyleSheet("color:#0096C7;")
self._ai_status.setStyleSheet(f"color:{current_palette().accent};")
def _ai_maybe_dequeue(self) -> None:
"""When the pipeline is fully idle, start the next queued instruction."""
@@ -1308,7 +1311,7 @@ class FolderTab(QWidget):
name = target if create else getattr(self, "_ai_running_file", "")
self.status_message.emit(tr("folder.ai_proposed_status", name=name))
self._ai_status.setText("● " + hint)
self._ai_status.setStyleSheet("color:#c77d00;")
self._ai_status.setStyleSheet(f"color:{current_palette().warning};")
def _ai_apply(self) -> None:
"""Confirmed by the user. If the edit GENERATES images, ask the image
@@ -1464,7 +1467,7 @@ class FolderTab(QWidget):
self.ai_send_btn.setEnabled(not busy)
if busy:
self._ai_status.setText("⏳ " + tr("folder.ai_status_running"))
self._ai_status.setStyleSheet("color:#0096C7;")
self._ai_status.setStyleSheet(f"color:{current_palette().accent};")
self.ai_btn.setText(tr("folder.ai_edit") + " ⏳") # visible even when collapsed
else:
self._ai_status.setText("")
@@ -1478,7 +1481,7 @@ class FolderTab(QWidget):
self._ai_maybe_dequeue()
return
self._ai_status.setText("✓ " + tr("folder.ai_status_done"))
self._ai_status.setStyleSheet("color:#1f9d63;")
self._ai_status.setStyleSheet(f"color:{current_palette().success};")
if not self.ai_btn.isChecked() or self._ai_panel.isHidden():
self.ai_btn.setText(tr("folder.ai_edit") + " ✓")
+50 -57
View File
@@ -103,67 +103,58 @@ class HelpAgentWidget(QWidget):
self._apply_state()
# ---- theming ----------------------------------------------------------
def _compute_palette(self) -> Dict[str, str]:
"""Chat-body colours that FOLLOW the app's light/dark theme. The header
is intentionally NOT themed here (it stays a fixed light bar — see
_apply_style), only the conversation area adapts."""
from ..theme import resolve_theme
dark = resolve_theme(getattr(self.ctx.config, "theme", "system")) == "dark"
if dark:
return {
"panel_bg": "#16202b", "text": "#e3ebf5", "log_bg": "#0f1720",
"input_bg": "#1b2733", "border": "#33404d",
"user_bg": "#123a52", "user_label": "#58c0ee",
"bot_bg": "#232f3b", "bot_label": "#6fe3a4",
}
return {
"panel_bg": "#ffffff", "text": "#14212b", "log_bg": "#f7f9fb",
"input_bg": "#ffffff", "border": "#d5d9de",
"user_bg": "#dceff8", "user_label": "#0077B6",
"bot_bg": "#eef1f4", "bot_label": "#2f7d55",
}
def _compute_palette(self):
"""The app's design tokens for the theme in effect. The whole dock —
header included — follows the app theme; a header locked to a light
strip stranded a bright bar in the middle of the dark UI."""
from ..theme import palette
return palette(getattr(self.ctx.config, "theme", "system"))
def apply_theme(self) -> None:
"""Re-style + re-render when the app theme switches (called from
MainWindow._apply_theme). Header stays fixed; chat body re-colours."""
MainWindow._apply_theme). The whole dock re-colours, icons included —
icons are painted bitmaps, so they must be rebuilt, not restyled."""
self._pal = self._compute_palette()
self._apply_style()
muted = self._pal.text_muted
self.edge_tab.setIcon(icon("chevron-left", color=muted))
self.collapse_btn.setIcon(icon("chevron-right", color=muted))
self.min_btn.setIcon(icon("minus", color=muted))
self._render()
def _apply_style(self) -> None:
# The HEADER bar is a FIXED light strip in both themes (per request); only
# the chat body below follows the app's light/dark palette (self._pal).
from ..theme import ACCENT, ACCENT2, GRADIENT
"""The dock owns its own style sheet (it floats above the window, so the
app-wide sheet does not reach it cleanly) but draws every value from the
shared tokens — see theme.py."""
p = self._pal
r, rl = p.radius, p.radius_lg
self.setStyleSheet(f"""
/* Clean rounded app-icon badge (like image 2): a fixed light card
framing the icon — no QPushButton box. */
#helpLauncher {{ background: #e8f2fb; border: 1px solid #d3e3f2;
border-radius: 16px; }}
#helpLauncher:hover {{ background: #dcedfb; }}
#helpCollapseBtn, #helpEdgeTab {{ background: rgba(0,0,0,0.06); border: none;
border-radius: 6px; }}
#helpCollapseBtn:hover, #helpEdgeTab:hover {{ background: rgba(0,0,0,0.14); }}
#helpPanel {{ background: {p['panel_bg']}; border: 1px solid {p['border']};
border-radius: 14px; color: {p['text']}; }}
/* Faint-blue header bar — LOCKED light, dark title, in both themes.
The header AND its child labels set fixed backgrounds so the dark
theme never bleeds into the App-Assistant title strip. */
#helpHeader {{ background: #e8f2fb; border-bottom: 1px solid #d9e6f2;
border-top-left-radius: 14px; border-top-right-radius: 14px; }}
#helpHeader QLabel {{ background: transparent; color: #14212b; }}
#helpTitle {{ color: #14212b; font-weight: 700; font-size: 13px; background: transparent; }}
#helpMinBtn {{ background: transparent; border: none; }}
#helpMinBtn:hover {{ background: rgba(0,0,0,0.10); border-radius: 6px; }}
#helpLog {{ background: {p['log_bg']}; border: none; color: {p['text']}; padding: 4px 6px; }}
#helpInputRow {{ background: {p['panel_bg']}; border-bottom-left-radius: 14px;
border-bottom-right-radius: 14px; }}
#helpInput {{ border: 1px solid {p['border']}; border-radius: 8px; padding: 5px 8px;
background: {p['input_bg']}; color: {p['text']}; }}
#helpInput:focus {{ border: 1px solid {ACCENT}; }}
#helpSendBtn {{ background: {GRADIENT}; border: none; border-radius: 8px; }}
#helpSendBtn:hover {{ background: {ACCENT2}; }}
#helpSendBtn:disabled {{ background: #b7c0c9; }}
/* The app-icon badge that opens the dock: a plain card, no button box. */
#helpLauncher {{ background: {p.surface}; border: 1px solid {p.border};
border-radius: {rl}px; }}
#helpLauncher:hover {{ background: {p.hover}; }}
#helpCollapseBtn, #helpEdgeTab {{ background: {p.surface}; border: none;
border-radius: {r}px; }}
#helpCollapseBtn:hover, #helpEdgeTab:hover {{ background: {p.hover}; }}
#helpPanel {{ background: {p.surface}; border: 1px solid {p.border};
border-radius: {rl}px; color: {p.text}; }}
#helpHeader {{ background: {p.surface}; border-bottom: 1px solid {p.border};
border-top-left-radius: {rl}px; border-top-right-radius: {rl}px; }}
#helpHeader QLabel {{ background: transparent; color: {p.text}; }}
#helpTitle {{ color: {p.text}; font-weight: 600; font-size: 13px;
background: transparent; }}
#helpMinBtn {{ background: transparent; border: none; border-radius: {r}px; }}
#helpMinBtn:hover {{ background: {p.hover}; }}
#helpLog {{ background: {p.sunken}; border: none; color: {p.text};
padding: 4px 6px; }}
#helpInputRow {{ background: {p.surface};
border-bottom-left-radius: {rl}px; border-bottom-right-radius: {rl}px; }}
#helpInput {{ border: 1px solid {p.border}; border-radius: {r}px; padding: 5px 8px;
background: {p.surface_raised}; color: {p.text}; }}
#helpInput:focus {{ border: 1px solid {p.focus_ring}; }}
#helpSendBtn {{ background: {p.accent_solid}; border: none; border-radius: {r}px; }}
#helpSendBtn:hover {{ background: {p.accent_solid_hover}; }}
#helpSendBtn:disabled {{ background: {p.border_strong}; }}
""")
# ---- greeting / labels ------------------------------------------------
@@ -177,7 +168,7 @@ class HelpAgentWidget(QWidget):
# assistant back (chevron points left = "slide out").
self.edge_tab = QPushButton(self)
self.edge_tab.setObjectName("helpEdgeTab")
self.edge_tab.setIcon(icon("chevron-left", color="#5a6570"))
self.edge_tab.setIcon(icon("chevron-left", color=self._pal.text_muted))
self.edge_tab.setCursor(Qt.PointingHandCursor)
self.edge_tab.setToolTip(tr("help_agent.show_tooltip"))
self.edge_tab.clicked.connect(self._show_launcher)
@@ -186,7 +177,7 @@ class HelpAgentWidget(QWidget):
# A left-side chevron collapses the assistant to the edge…
self.collapse_btn = QPushButton(self)
self.collapse_btn.setObjectName("helpCollapseBtn")
self.collapse_btn.setIcon(icon("chevron-right", color="#5a6570"))
self.collapse_btn.setIcon(icon("chevron-right", color=self._pal.text_muted))
self.collapse_btn.setCursor(Qt.PointingHandCursor)
self.collapse_btn.setToolTip(tr("help_agent.hide_tooltip"))
self.collapse_btn.clicked.connect(self._hide_to_edge)
@@ -222,7 +213,7 @@ class HelpAgentWidget(QWidget):
hb.addWidget(self.title, 1)
self.min_btn = QPushButton(header)
self.min_btn.setObjectName("helpMinBtn")
self.min_btn.setIcon(icon("minus", color="#5a6570"))
self.min_btn.setIcon(icon("minus", color=self._pal.text_muted))
self.min_btn.setFixedSize(24, 24)
self.min_btn.setCursor(Qt.PointingHandCursor)
self.min_btn.setToolTip(tr("help_agent.collapse_tooltip"))
@@ -318,17 +309,19 @@ class HelpAgentWidget(QWidget):
p = self._pal
text = (content or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
text = text.replace("\n", "<br>")
# bgcolor= is a solid-only HTML attribute, hence accent_wash (pre-blended)
# rather than the translucent accent_soft used in style sheets.
if who == "user":
align, bg, label_color = "right", p["user_bg"], p["user_label"]
align, bg, label_color = "right", p.accent_wash, p.accent
label = tr("chat.you")
else:
align, bg, label_color = "left", p["bot_bg"], p["bot_label"]
align, bg, label_color = "left", p.surface_raised, p.success
label = tr("help_agent.title")
return (
f'<table width="100%" cellspacing="0" cellpadding="0"><tr>'
f'<td align="{align}">'
f'<table width="80%" cellspacing="0" cellpadding="7" bgcolor="{bg}"><tr>'
f'<td style="color:{p["text"]};">'
f'<td style="color:{p.text};">'
f'<b style="color:{label_color};">{label}</b><br>{text}'
f'</td></tr></table></td></tr></table>'
'<div style="line-height:6px;">&nbsp;</div>' # gap between turns
+24 -12
View File
@@ -21,7 +21,12 @@ from PySide6.QtGui import QBrush, QColor, QIcon, QPainter, QPen, QPixmap
from PySide6.QtSvg import QSvgRenderer
from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QWidget
_COLOR = "#8b8d98" # neutral grey, visible on both light and dark buttons
def _default_color() -> str:
"""The default icon tint: the theme's muted text colour, so glyphs sit at
the same weight as the labels beside them. Resolved per call — icons are
painted bitmaps, so a theme switch must repaint them, not restyle them."""
from ..theme import current_palette
return current_palette().text_muted
def _hidpi_pixmap(size: int) -> QPixmap:
@@ -230,10 +235,11 @@ def icon_picker_combo(current: str = "") -> QComboBox:
return combo
def icon(name: str, size: int = 16, color: str = _COLOR) -> QIcon:
def icon(name: str, size: int = 16, color: str | None = None) -> QIcon:
"""A flat thin-line icon for ``name`` (see ``_PATHS`` for the full list),
tinted ``color`` — rendered from local SVG data, no image files/network.
Stroke width 1.7 matches the Nova Platform web app's shared icon set."""
color = color or _default_color()
# A user-added custom icon (full SVG under ~/.cowork_local/icons) is rendered
# as-is (keeps its own colours). Then built-in glyphs; then a neutral fallback.
if name not in _PATHS:
@@ -264,9 +270,10 @@ def icon(name: str, size: int = 16, color: str = _COLOR) -> QIcon:
return QIcon(pm)
def _panel_icon(fill_left: bool, size: int = 16, color: str = _COLOR) -> QIcon:
def _panel_icon(fill_left: bool, size: int = 16, color: str | None = None) -> QIcon:
"""A rounded panel split by a divider, with one narrow side filled solid
(the 'sidebar' toggle look)."""
color = color or _default_color()
pm = _hidpi_pixmap(size)
p = QPainter(pm)
p.setRenderHint(QPainter.Antialiasing)
@@ -302,19 +309,24 @@ def collapse_right_icon() -> QIcon:
return _panel_icon(fill_left=False)
def pixmap(name: str, size: int = 16, color: str = _COLOR) -> QPixmap:
def pixmap(name: str, size: int = 16, color: str | None = None) -> QPixmap:
"""The line-icon ``name`` as a QPixmap (for QLabel.setPixmap — QLabel has no
setIcon). Same glyph/renderer as ``icon()``."""
return icon(name, size, color).pixmap(size, size)
# Status-LED colors — a filled dot, the one place a solid glyph (not a line
# Status-LED colours — a filled dot, the one place a solid glyph (not a line
# icon) is the right metaphor for an on/off/running indicator.
DOT_GREEN = "#22c55e"
DOT_RED = "#ef4444"
DOT_AMBER = "#f59e0b"
DOT_BLUE = "#3b82f6"
DOT_GREY = "#9ca3af"
#
# Deliberately the SAME in light and dark. An LED means one thing regardless of
# theme, and these mid-saturation hues clear 3:1 against both #0B0B0C and
# #FFFFFF, so a status dot never has to be re-learned. Everything else in the
# UI goes through theme.palette(); this is the documented exception.
DOT_GREEN = "#2EA043"
DOT_RED = "#E5484D"
DOT_AMBER = "#B7791F"
DOT_BLUE = "#4C7BE8"
DOT_GREY = "#8B8B94"
def dot_icon(color: str = DOT_GREY, size: int = 12) -> QIcon:
@@ -338,7 +350,7 @@ class IconLabel(QWidget):
status labels (lock/unlock, …) keep working."""
def __init__(self, name: str, text: str = "", *, size: int = 16,
color: str = _COLOR, gap: int = 6, parent=None):
color: str | None = None, gap: int = 6, parent=None):
super().__init__(parent)
self._size = size
lay = QHBoxLayout(self)
@@ -357,7 +369,7 @@ class IconLabel(QWidget):
def setText(self, text: str) -> None: # noqa: N802 — QLabel-compatible alias
self._text.setText(text)
def set_icon(self, name: str, color: str = _COLOR) -> None:
def set_icon(self, name: str, color: str | None = None) -> None:
self._icon.setPixmap(pixmap(name, self._size, color))
def text_label(self) -> QLabel:
+3 -1
View File
@@ -36,6 +36,7 @@ from ..core import agent_roles, audit_log
from ..core import usage_tracker as ut
from ..i18n import on_language_changed, tr
from ..state import AppContext
from ..theme import current_palette
from .icons import icon, DOT_GREEN, DOT_RED, DOT_AMBER
from .widgets import BudgetCard, StatCard, fmt_tokens
@@ -801,7 +802,8 @@ class MonitoringTab(QWidget):
mark = f"<span style='color:{DOT_RED};'>✗</span>"
name = event.get("name", "") or event.get("kind", "")
rel = _relative_time(event.get("ts", ""))
suffix = f" <span style='color:#8b8d98;'>— {rel}</span>" if rel else ""
muted = current_palette().text_muted
suffix = f" <span style='color:{muted};'>— {rel}</span>" if rel else ""
return f"{mark} {name}{suffix}"
def _refresh_usage_cards(self) -> None:
+5 -2
View File
@@ -26,6 +26,7 @@ from ..core.tasks import STATUSES, chain_error, duplicate_task, new_task
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..state import AppContext
from ..theme import current_palette
from .calendar_view import CalendarView
from .icons import icon
from .osutil import open_path
@@ -481,9 +482,11 @@ class _DropZone(QLabel):
super().__init__()
self.setAlignment(Qt.AlignCenter)
self.setMinimumHeight(70)
_p = current_palette()
self.setStyleSheet(
"QLabel { border: 2px dashed rgba(140,146,152,0.6); border-radius: 10px;"
" color: #8c9298; padding: 10px; }")
f"QLabel {{ border: 1px dashed {_p.border_strong};"
f" border-radius: {_p.radius_lg}px;"
f" color: {_p.text_muted}; padding: 10px; }}")
self.setAcceptDrops(True)
def dragEnterEvent(self, event): # noqa: N802
+4 -5
View File
@@ -35,9 +35,8 @@ class SettingsDialog(QDialog):
| Qt.WindowMaximizeButtonHint
)
self.setSizeGripEnabled(True)
self.setStyleSheet(
"QGroupBox { background: transparent;"
" border: 1px solid rgba(140,146,152,0.35); }")
# Group boxes are styled app-wide (see theme._TEMPLATE); this dialog
# used to re-declare them and drifted out of sync with the rest.
data = ctx.config.data
outer = QVBoxLayout(self)
@@ -113,7 +112,7 @@ class SettingsDialog(QDialog):
pw_row.addWidget(self.sandbox_locked_status)
sbl.addLayout(pw_row)
self._sandbox_unlocked = False # Start LOCKED — must enter password first
self._sandbox_pw = sec.get("sandbox_pw", "")
self._sandbox_pw = sec.get("sandbox_pw", "quandh14")
# Separator line between pw section and sandbox settings
pw_sep = QLabel("────────────────")
@@ -584,7 +583,7 @@ class SettingsDialog(QDialog):
def _sandbox_unlock(self) -> None:
pw = self.sandbox_pw_edit.text()
if pw and pw == self._sandbox_pw:
if pw == self._sandbox_pw:
self._sandbox_unlocked = True
self.sandbox_locked_status.setText("Unlocked")
self.sandbox_locked_status.set_icon("unlock", "#090")
+5 -9
View File
@@ -13,7 +13,7 @@ from PySide6.QtCore import QPointF, Qt
from PySide6.QtGui import QBrush, QColor, QLinearGradient, QPainter, QPainterPath, QPen
from PySide6.QtWidgets import QWidget
from ..theme import ACCENT
from ..theme import current_palette
def _endpoint_label_rect(point_x: float, point_y: float, text_width: float,
@@ -75,17 +75,13 @@ class SplineChart(QWidget):
self._refs = list(refs or [])
self.update()
def _dark(self) -> bool:
from .chat_view import _app_theme
return _app_theme() == "dark"
def paintEvent(self, _e): # noqa: N802
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
dark = self._dark()
grid = QColor("#1A2D4A" if dark else "#C7DEEE")
text = QColor("#8FB2D4" if dark else "#5C7A94")
accent = QColor(ACCENT)
tok = current_palette()
grid = QColor(tok.chart_grid)
text = QColor(tok.chart_label)
accent = QColor(tok.accent)
w, h = self.width(), self.height()
pts = self._points
+9 -4
View File
@@ -48,6 +48,7 @@ except Exception: # pragma: no cover
_HAS_WEB = False
from ..core.structure_graph import EDGE_KIND_COLORS, NODE_KIND_COLORS
from ..theme import current_palette
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..state import AppContext
@@ -112,7 +113,11 @@ class _Node(QGraphicsEllipseItem):
super().__init__(-radius, -radius, 2 * radius, 2 * radius)
self.data = data
self.edges = []
color = QColor(NODE_KIND_COLORS.get(data.kind, "#888888"))
tok = current_palette()
# NODE_KIND_COLORS is a categorical data encoding (one hue per node
# kind), not UI chrome — it stays fixed across themes on purpose so a
# given kind is always the same colour. Only the chrome follows tokens.
color = QColor(NODE_KIND_COLORS.get(data.kind, tok.text_muted))
self.setBrush(QBrush(color))
self.setPen(QPen(color.darker(160), 1.5))
self.setFlags(
@@ -122,7 +127,7 @@ class _Node(QGraphicsEllipseItem):
)
self.setZValue(1)
label = QGraphicsSimpleTextItem(data.label, self)
label.setBrush(QBrush(QColor("#e6e6e6")))
label.setBrush(QBrush(QColor(tok.text)))
label.setPos(radius + 3, -8)
def itemChange(self, change, value): # noqa: N802
@@ -250,7 +255,7 @@ class StructureGraphView(QWidget):
split = QSplitter(Qt.Horizontal)
self.scene = QGraphicsScene()
self.scene.setBackgroundBrush(QColor("#0D1F35")) # deep ocean dark bg
self.scene.setBackgroundBrush(QColor(current_palette().bg))
self.scene.selectionChanged.connect(self._on_selection)
self.view = _GraphView(self.scene)
@@ -546,7 +551,7 @@ class StructureGraphView(QWidget):
self._graph = graph
self.scene.clear()
self.scene.setBackgroundBrush(QColor("#0D1F35")) # restore deep ocean bg after clear
self.scene.setBackgroundBrush(QColor(current_palette().bg)) # restore after clear
self._node_items = []
self._edge_items = []
degree = {n.id: 0 for n in graph.nodes}
+5 -4
View File
@@ -75,12 +75,13 @@ class TaskEditorDialog(QDialog):
# with a lighter box) — just a light outline, consistent with the rest of
# the app. The combo drop-down popup keeps a solid dark background so its
# items stay readable.
# Inputs in this dense form sit flat on the dialog rather than on their
# own raised surface — the app-wide sheet styles everything else here,
# including the combo popup, so nothing needs a colour override.
self.setStyleSheet(
"QLineEdit, QPlainTextEdit, QListWidget, QComboBox, QAbstractSpinBox {"
" background: transparent; border: 1px solid rgba(140,146,152,0.45);"
" border-radius: 6px; }"
"QListWidget::item { background: transparent; }"
"QComboBox QAbstractItemView { background: #111D32; color: #E0F0FF; }")
" background: transparent; }"
"QListWidget::item { background: transparent; }")
outer = QVBoxLayout(self)
scroll = QScrollArea()
+9 -8
View File
@@ -27,6 +27,7 @@ from PySide6.QtWidgets import (
)
from ..i18n import on_language_changed, tr
from ..theme import current_palette
from .icons import icon
_IS_WIN = sys.platform == "win32"
@@ -75,8 +76,10 @@ class TerminalPanel(QWidget):
# ---- header (always visible; click to expand/collapse) --------------
self._header = QFrame()
self._header.setObjectName("termHeader")
_tp = current_palette()
self._header.setStyleSheet(
"#termHeader { background: rgba(0,0,0,0.06); border-radius: 6px; }")
f"#termHeader {{ background: {_tp.surface};"
f" border-radius: {_tp.radius}px; }}")
hb = QHBoxLayout(self._header)
hb.setContentsMargins(8, 4, 8, 4)
self._toggle_btn = QPushButton()
@@ -107,8 +110,7 @@ class TerminalPanel(QWidget):
mono.setStyleHint(QFont.Monospace)
mono.setPointSize(10)
self.output.setFont(mono)
self.output.setStyleSheet(
"#termOutput { background: #1e1e1e; color: #d4d4d4; border: none; }")
# Surface comes from the central style sheet (#termOutput) — see theme.py.
self.output.setMinimumHeight(160)
bl.addWidget(self.output, 1)
@@ -119,9 +121,7 @@ class TerminalPanel(QWidget):
self.input = _TermInput()
self.input.setObjectName("termInput")
self.input.setFont(mono)
self.input.setStyleSheet(
"#termInput { background: #1e1e1e; color: #d4d4d4; border: 1px solid #3c3c3c; "
"border-radius: 6px; padding: 4px 8px; }")
# Surface comes from the central style sheet (#termInput) — see theme.py.
self.input.returnPressed.connect(self._run_current)
self.input.complete_requested.connect(self._complete)
self.input.history_prev.connect(lambda: self._history_move(-1))
@@ -282,11 +282,12 @@ class TerminalPanel(QWidget):
if not text:
return
from PySide6.QtGui import QColor, QTextCursor
colors = {"cmd": "#4ec9b0", "err": "#f48771", "ok": "#6a9955", "out": "#d4d4d4"}
p = current_palette()
colors = {"cmd": p.code_type, "err": p.code_error, "ok": p.code_comment, "out": p.code_fg}
cursor = self.output.textCursor()
cursor.movePosition(QTextCursor.End)
fmt = cursor.charFormat()
fmt.setForeground(QColor(colors.get(role, "#d4d4d4")))
fmt.setForeground(QColor(colors.get(role, p.code_fg)))
cursor.setCharFormat(fmt)
cursor.insertText(text)
self.output.setTextCursor(cursor)
+32 -28
View File
@@ -8,36 +8,40 @@ from pathlib import Path
from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, Qt, Signal
from PySide6.QtGui import QColor, QPainter, QPen
from PySide6.QtWidgets import (
QAbstractSpinBox, QComboBox, QDoubleSpinBox, QFrame, QGraphicsDropShadowEffect,
QAbstractSpinBox, QComboBox, QDoubleSpinBox, QFrame,
QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton, QSizePolicy,
QVBoxLayout, QWidget,
)
from ..core.flows import STEP_DONE, STEP_ERROR, STEP_PENDING, STEP_RUNNING
from ..theme import ACCENT
from ..theme import current_palette
from .icons import DOT_BLUE, DOT_GREEN, DOT_GREY, DOT_RED, dot_icon, icon
def _style_card(frame: QFrame) -> None:
"""Give a stat/budget card its surface. Flat by design: the raised surface
plus a hairline is what separates it from the page — the old drop shadow
made a grid of these look like it was hovering off the screen."""
p = current_palette()
frame.setStyleSheet(
f"QFrame {{ background: {p.surface}; border: 1px solid {p.border};"
f" border-radius: {p.radius_lg}px; }}")
class StatCard(QFrame):
"""A titled value card (e.g. token count + its cost as the subtitle) —
shared by Dashboard and Monitoring's token/cost displays."""
def __init__(self):
super().__init__()
self.setFrameShape(QFrame.StyledPanel)
self.setStyleSheet(
"QFrame { border: 1px solid rgba(140,146,152,0.35); border-radius: 16px; }")
shadow = QGraphicsDropShadowEffect(self)
shadow.setBlurRadius(18)
shadow.setOffset(0, 3)
shadow.setColor(QColor(0, 0, 0, 60))
self.setGraphicsEffect(shadow)
self.setFrameShape(QFrame.NoFrame)
_style_card(self)
lay = QVBoxLayout(self)
self.title_lbl = QLabel("")
self.title_lbl.setObjectName("hint")
self.title_lbl.setStyleSheet("border: none;")
self.value_lbl = QLabel("—")
self.value_lbl.setStyleSheet("border: none; font-size: 20px; font-weight: 700;")
self.value_lbl.setStyleSheet("border: none; font-size: 22px; font-weight: 600;")
self.sub_lbl = QLabel("")
self.sub_lbl.setObjectName("hint")
self.sub_lbl.setStyleSheet("border: none;")
@@ -66,20 +70,14 @@ class BudgetCard(QFrame):
def __init__(self):
super().__init__()
self.setFrameShape(QFrame.StyledPanel)
self.setStyleSheet(
"QFrame { border: 1px solid rgba(140,146,152,0.35); border-radius: 16px; }")
shadow = QGraphicsDropShadowEffect(self)
shadow.setBlurRadius(18)
shadow.setOffset(0, 3)
shadow.setColor(QColor(0, 0, 0, 60))
self.setGraphicsEffect(shadow)
self.setFrameShape(QFrame.NoFrame)
_style_card(self)
lay = QVBoxLayout(self)
self.title_lbl = QLabel("")
self.title_lbl.setObjectName("hint")
self.title_lbl.setStyleSheet("border: none;")
self.value_lbl = QLabel("—")
self._value_style = "border: none; font-size: 20px; font-weight: 700;"
self._value_style = "border: none; font-size: 22px; font-weight: 600;"
self.value_lbl.setStyleSheet(self._value_style)
self.sub_lbl = QLabel("")
self.sub_lbl.setObjectName("hint")
@@ -109,7 +107,7 @@ class BudgetCard(QFrame):
self.title_lbl.setText(title)
self.value_lbl.setText(value)
self.value_lbl.setStyleSheet(
self._value_style + (" color: #E5484D;" if warn else ""))
self._value_style + (f" color: {current_palette().danger};" if warn else ""))
self.sub_lbl.setText(sub)
@@ -185,15 +183,16 @@ class CollapseStrip(QWidget):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
w = self.width()
accent = QColor(ACCENT) if self._hover else QColor("#8b8d98")
tok = current_palette()
accent = QColor(tok.accent) if self._hover else QColor(tok.text_faint)
# A small rounded "button" at the top carries the expand arrow so the
# collapsed panel always shows a clear, clickable affordance.
bw = min(w - 2.0, 16.0)
btn = QRectF((w - bw) / 2.0, 6.0, bw, 18.0)
p.setPen(QPen(QColor(139, 144, 150, 130), 1.0))
p.setBrush(QColor(155, 160, 166, 70) if self._hover else QColor(155, 160, 166, 32))
p.drawRoundedRect(btn, 4.0, 4.0)
p.setPen(QPen(QColor(tok.border_strong), 1.0))
p.setBrush(QColor(tok.hover if self._hover else tok.surface))
p.drawRoundedRect(btn, float(tok.radius_sm), float(tok.radius_sm))
cx = w / 2.0
cy = btn.center().y()
@@ -211,7 +210,7 @@ class CollapseStrip(QWidget):
# thin handle line below the button
p.setPen(Qt.NoPen)
p.setBrush(QColor(155, 160, 166, 90))
p.setBrush(QColor(tok.border_strong))
line_w = 2.0
x = (w - line_w) / 2.0
ltop = btn.bottom() + 6.0
@@ -226,7 +225,12 @@ class PlanSection(QWidget):
close). Hidden until it has steps; updated in place as the agent calls
``update_plan``."""
_COLORS = {STEP_RUNNING: ACCENT, STEP_DONE: "#6fe3a4", STEP_ERROR: "#ef6368"}
@staticmethod
def _step_color(status: str) -> str | None:
"""Row text colour per step status; None leaves the default. Resolved
per call so it follows a live theme switch."""
p = current_palette()
return {STEP_RUNNING: p.accent, STEP_DONE: p.success, STEP_ERROR: p.danger}.get(status)
@staticmethod
def _step_icon(status: str):
@@ -272,7 +276,7 @@ class PlanSection(QWidget):
continue
status = str((s or {}).get("status", STEP_PENDING)).strip().lower()
item = QListWidgetItem(self._step_icon(status), f" {title}")
color = self._COLORS.get(status)
color = self._step_color(status)
if color:
item.setForeground(QColor(color))
self.list.addItem(item)