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>
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"> · 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 & 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 & 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 & 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ụ & 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 & 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 & 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á & 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ả & 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><project>/.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 & 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> <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>
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -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": ""
|
||||
}
|
||||
]
|
||||
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 74 KiB |
@@ -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: {}
|
||||
@@ -0,0 +1,9 @@
|
||||
PySide6>=6.6
|
||||
pydantic>=2
|
||||
requests
|
||||
psutil
|
||||
pygments
|
||||
openpyxl
|
||||
python-pptx
|
||||
networkx
|
||||
pytest
|
||||
@@ -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)
|
||||
@@ -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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & gọi API AI</li>
|
||||
</ul>
|
||||
<div class="card-title" style="margin-top:1rem">Cài & 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 & 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 & Đ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 & 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 & 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>
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
@@ -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:]))
|
||||
@@ -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.")
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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 " "
|
||||
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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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") + " ✓")
|
||||
|
||||
|
||||
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
||||
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;"> </div>' # gap between turns
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||