Compare commits

...
Author SHA1 Message Date
anhtnm1andClaude Opus 5 6d3217e0b5 docs(refactor): add the Team Duy completion report for R01/R03/R04
docs/refactor/BaoCao_TeamDuy_R01_R03_R04.md records what was delivered against
each of the 16 tasks, the measured evidence (243 tests, 218 of them in 1.22s;
check_imports PASS; no production file over 400 LOC), the three real defects
found while working - the routing_application() deadlock, the swallowed
"notice" event, and the suite silently testing a different checkout - plus the
six open decisions and, explicitly, what was NOT tested (no manual app launch,
no real provider traffic, tools/check_*.py not run).

Refactoring_Checklist.md now links to it from the progress block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:58:36 +09:00
anhtnm1andClaude Opus 5 67b8d2edbb docs(refactor): correct the Team Duy scope block in the checklist
The previous commit recorded Team Duy as owning R01/R02/R04/R10. That is wrong.
Feature_Architecture_Proposal.md line 7 and DeltaTeam_prompt.md line 17 both
state R01, R03, R04, R08 (Chat UI) and R10; R02 belongs to Team Nam, which is
also who owns the two failing config-security tests.

The completed work itself (R01, R03, R04) was already correct and is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:52:40 +09:00
anhtnm1andClaude Opus 5 15e1d3eb65 test(R03/R04): cover the three code paths that were changed but never executed
Verification gap closed. The suite proved the new services correct in isolation,
but three paths I had modified had no test actually running them:

tests/integration/test_task_executor_flow.py (7 tests)
  The Schedule Task path after R04-T05. Pins that History is still re-saved from
  the LIVE message list mid-run (the reason begin_turn() exists - the pre-turn
  copy would have frozen progress at the first user message), that update_plan
  tracking still reports an unfinished checklist, and that a failed run still
  raises so execute_task writes error.txt.

tests/integration/test_routing_surfaces.py (11 tests)
  Real offscreen CoworkTab/Co4ETab/FolderTab calling the shared routing service:
  correct surface key per screen, Auto switches, Off does not consult the engine,
  Manual switches only on approval, a pinned Admin agent still wins, and AI-Edit
  still pins TaskType.CODING. Also pins the field contract ui/routing_toggle.py
  reads off RoutingDecision (from_model/to_model as provider/model keys) - a
  rename there would only fail inside a modal dialog.

Also updates docs/refactor/Refactoring_Checklist.md: the 16 completed R01/R03/R04
tasks, the Team Duy daily rows, and a status block recording the measured
numbers, the scope correction (team owns R01/R02/R04/R10), and what is still
outstanding.

Suite: 243 passed, 2 pre-existing failures (EPIC R02). Fast suite (unit +
contracts + characterization + routing): 218 passed in 1.16s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:45:05 +09:00
anhtnm1andClaude Opus 5 a53163ebaf feat(R04): immutable turn snapshot, typed agent events, conversation service
EPIC R04 (Team Duy) - the turn lifecycle leaves the widget.

R04-T01 domain/agents/conversation_execution_request.py
  Frozen snapshot of one turn, captured on the UI thread at submit time. The
  job closure used to read widget/workspace state from inside the worker
  thread, so a turn could run on a mix of submit-time and later state
  depending on thread timing.
R04-T02 domain/agents/agent_event.py
  13 frozen event types replacing untyped emit() dicts, with a two-way bridge
  so existing widgets keep consuming the legacy shape until EPIC R08. Adds
  TurnCompletedEvent - the end-of-turn signal the engine never had, which is
  why a cancelled turn and a failed turn look identical to the UI today.
R04-T03 application/conversations/conversation_application_service.py
  Runs a turn from a request and reports typed events. Never raises across the
  worker boundary; TurnResult.raise_if_failed() preserves the existing
  exception-based failure path. begin_turn()/execute_turn() expose the live
  message list for callers that autosave history mid-run.
R04-T04 ui/cowork_tab.py::build_job -> snapshot + service.
R04-T05 core/task_executors.py::_run_agent -> same service (was a second,
  slightly different assembly of the same call).

Caught while wiring the bridge: the first event vocabulary had no "notice"
event, so Agent Security warnings and auto-compaction notices would have been
silently swallowed. Added NoticeEvent plus a test that scans the engine sources
for emit() tags and fails when one has no typed counterpart.

New: tests/integration/ - real offscreen CoworkTab running a scripted turn end
to end (7 tests), including a characterisation of the extra provider call Agent
Security spends reviewing each request.

Suite: 225 passed, 2.74s. check_imports: PASS. All new files < 400 LOC.
2 pre-existing failures remain in test_config_security.py (EPIC R02/Team Nam).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:32:14 +09:00
anhtnm1andClaude Opus 5 96bec976e7 feat(R03): unify provider catalogue, routing decisions and usage telemetry
EPIC R03 (Team Duy) - one provider catalogue, one routing flow, one usage seam.

R03-T01 tests/contracts/test_providers.py
  29 contract tests every provider must satisfy: canonical assistant message,
  streamed text == returned content, reasoning never joins the answer, parsed
  tool arguments, ProviderError for every failure. Real adapters exercised
  offline by stubbing Provider._request.
R03-T02 domain/models/provider_descriptor.py
        infrastructure/providers/provider_registry.py
  Provider facts declared once (was split across providers/factory.py,
  DEFAULT_CONFIG and PROVIDER_LABELS). ProviderRegistry.build() also stamps the
  descriptor id onto the instance, so ollama/github_copilot/codex usage is no
  longer all attributed to "openai_compat", and never mutates the caller config.
R03-T03 application/model_routing/routing_application_service.py
  Pure-Python routing policy with four modes: Off, Auto, Manual and the new
  Fallback (switch only AFTER the current model fails). Depends on a RoutingPort
  protocol; production wires the existing core.routing engine underneath.
R03-T04/T05 ui/chat_panel.py, ui/co4e_tab.py, ui/folder_tab.py
  Three near-identical routing copies (~40 lines each) replaced by a call to
  ctx.routing_application() plus a confirm callback. Mode vocabulary now lives
  in one place (normalize_mode/is_valid_mode) instead of four literal tuples.
R03-T06 infrastructure/telemetry/usage_sink.py
  Token usage extracted from both providers into UsageEvent + UsageEventSink.
  Estimation pinned against core.usage_tracker so no recorded number changes.

Also fixes a deadlock introduced while wiring AppContext: routing_application()
held _routing_lock and called routing(), which takes the same non-reentrant lock.

Suite: 186 passed, 1.22s. check_imports: PASS. All new files < 400 LOC.
2 pre-existing failures remain in test_config_security.py (EPIC R02/Team Nam).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:22:28 +09:00
anhtnm1andClaude Opus 5 bbc09f628a feat(R01): architecture foundation, offline fakes and characterization net
EPIC R01 (Team Duy) - safety net before the parallel refactor starts.

R01-T01 docs/architecture/ADR-001-layered-architecture.md
  4-tier boundaries, allowed dependency directions, invariants I1-I6 and
  the strangler-fig migration strategy.
R01-T02 tests/fakes/{fake_provider,fake_tool_executor}.py
  Scripted, offline Provider and extra-tool executor doubles.
R01-T03 scripts/check_imports.py
  AST-based Clean Architecture Guard (CASAN Check 3). Also covers relative
  imports and function-local imports; ASCII-only output for cp932 consoles.
R01-T04 tests/characterization/test_run_cowork.py
  13 snapshot tests pinning run_cowork's current observable contract before
  EPIC R04 moves its orchestration into application/.
R01-T05 docs/architecture/dormant-code.md
  Import-graph scan: 43 unimported modules verified down to 6 genuinely
  dormant items (~1887 LOC); the rest run via subprocess/CLI entry points.

tests/conftest.py binds `cowork_local` to THIS checkout by absolute path -
previously sys.path discovery could import a sibling checkout and the suite
would silently test the wrong code.

Suite: 104 passed, 1.08s (2 pre-existing failures in test_config_security.py
remain - config.py still ships a hardcoded default password, EPIC R02/Team Nam).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:05:50 +09:00
anhtnm1andClaude Opus 5 d633dffae6 docs(refactor): add plan.md with roadmap sections VI-IX
CI / test (pull_request) Canceled after 0s
Copy of sections VI-IX from Feature_Architecture_Proposal.md
(roadmap, team assignment/KPI, anti-patterns, function migration map).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 22:22:16 +09:00
anhtnm1andClaude Opus 5 73c9e4344c rename prompt.md to DeltaTeam_prompt.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 22:14:49 +09:00
huongltt35 2331b86db9 move file to docs folder 2026-08-20 22:05:52 +09:00
huongltt35 34626546b4 refactor plan 2026-08-20 22:00:53 +09:00
1419587401 Feature/fsg gamma team ui fix (#3)
CI / test (push) Canceled after 0s
## Summary

What changed and why?

## Change Type

- [ ] Cowork feature
- [ ] Bug fix
- [x] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Co-authored-by: NamPDT <minhanhpkpro@gmail.com>
Reviewed-on: #3
2026-08-20 12:12:56 +00:00
184 changed files with 31615 additions and 3907 deletions
+29
View File
@@ -0,0 +1,29 @@
# Normalise line endings so a Windows checkout and a Linux CI runner see the
# same bytes. Without this, committing from Windows records CRLF and every file
# shows as fully rewritten to anyone (or any CI job) on Linux.
* text=auto eol=lf
# Windows-only scripts must keep CRLF or cmd.exe mis-parses them.
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
# Binary: never touch, never try to diff as text.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.pdf binary
*.pptx binary
*.xlsx binary
*.docx binary
*.7z binary
*.zip binary
*.ttf binary
*.woff binary
*.woff2 binary
# The audit page is a single 8 MB file with base64 images inlined — a textual
# diff of it is noise, and merging it by hand is never the right move.
docs/ui-audit.html -diff -merge
+108 -25
View File
@@ -1,39 +1,122 @@
# 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
# Runtime data the app writes next to itself when it is run from the repo.
# A chat transcript got committed and pushed this way.
.cowork_history/
.cowork_local/
+99
View File
@@ -0,0 +1,99 @@
#!/bin/sh
# Boot the Cowork-Local PySide6 desktop app on Qt's built-in VNC server, then
# expose the live GUI to the browser through noVNC + websockify on :6080.
#
# This entrypoint is intended to be run from a `python:3.11-slim-bookworm`
# container via podman-compose. All setup (Qt runtime libs, noVNC, PySide6
# wheels) happens here so we don't need a custom image / Dockerfile.
# Idempotent: reruns are fast (apt reuses debs, pip uses the named cache vol).
set -e
log() { printf '[entrypoint] %s\n' "$*"; }
# ---------------------------------------------------------------------------
# 1. System runtime libraries: Qt6 needs EGL/GL/XCB; noVNC needs websockify.
# ---------------------------------------------------------------------------
if [ ! -f /var/cache/cowork_setup.stamp ]; then
log "installing apt packages (first run only)…"
apt-get update -qq
apt-get install -y --no-install-recommends \
libegl1 libgl1 libglib2.0-0 libdbus-1-3 libfontconfig1 \
libxkbcommon0 libxkbcommon-x11-0 libxcb-cursor0 \
libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 \
libxcb-render-util0 libxcb-shape0 libxcb-sync1 \
libxcb-xfixes0 libxcb-xinerama0 libxcb-xkb1 libxcb-util1 \
libxcomposite1 libxdamage1 libxrandr2 libxss1 libxtst6 \
libxi6 libxrender1 libfreetype6 \
fonts-dejavu fonts-noto-cjk \
netcat-openbsd ca-certificates >/dev/null
rm -rf /var/lib/apt/lists/*
touch /var/cache/cowork_setup.stamp
log "apt setup done."
else
log "apt setup already done (skipping)."
fi
# ---------------------------------------------------------------------------
# 2. Python dependencies (cached in the named `pip-cache` volume).
# `websockify` is shipped as a pip wheel and bundles the noVNC web
# assets under its package `web/` directory, so we don't need the
# (unavailable on slim-bookworm) apt `novnc` / `websockify` packages.
# ---------------------------------------------------------------------------
log "ensuring python deps…"
pip install --no-cache-dir --quiet \
"PySide6>=6.6" "pydantic>=2" requests psutil websockify numpy
log "python deps OK."
# ---------------------------------------------------------------------------
# 3. Make the workspace importable as `cowork_local` (the package name that
# `python -m cowork_local` and the test suite expect). Compose mounts the
# live repo at /workspace; we add a thin symlink at /opt/cowork_local.
# ---------------------------------------------------------------------------
ln -sfn /workspace /opt/cowork_local
export PYTHONPATH=/opt
# ---------------------------------------------------------------------------
# 4. Launch the app on Qt's built-in VNC platform (no Xvfb needed).
# Qt VNC listens on QT_QPA_VNC_HOST:QT_QPA_VNC_PORT (127.0.0.1:5900).
# ---------------------------------------------------------------------------
: > /var/log/app.log
log "launching app on Qt VNC platform…"
python -m cowork_local >/var/log/app.log 2>&1 &
APP_PID=$!
# Wait until the VNC socket accepts a connection (or give up after 60s).
for i in $(seq 1 120); do
if nc -z 127.0.0.1 "${QT_QPA_VNC_PORT:-5900}" 2>/dev/null; then
log "VNC server up on 127.0.0.1:${QT_QPA_VNC_PORT:-5900} (pid ${APP_PID})."
break
fi
if ! kill -0 "${APP_PID}" 2>/dev/null; then
log "app process died before VNC was up; log tail:"
tail -n 60 /var/log/app.log >&2 || true
exit 1
fi
sleep 0.5
done
if ! nc -z 127.0.0.1 "${QT_QPA_VNC_PORT:-5900}" 2>/dev/null; then
log "VNC never came up; log tail:"
tail -n 60 /var/log/app.log >&2 || true
exit 1
fi
# ---------------------------------------------------------------------------
# 5. Bridge browser -> VNC. websockify serves the noVNC web client on :6080
# and proxies WebSocket connections to the raw VNC server. The noVNC web
# assets are bundled inside the websockify pip wheel.
# ---------------------------------------------------------------------------
NOVNC_WEB="$(python - <<'PY'
import os, websockify
print(os.path.join(os.path.dirname(websockify.__file__), "web"))
PY
)"
[ -f "${NOVNC_WEB}/vnc.html" ] || { log "noVNC web assets not found at ${NOVNC_WEB}, aborting."; exit 1; }
log "noVNC web assets: ${NOVNC_WEB}"
log "starting noVNC bridge on 0.0.0.0:6080 -> 127.0.0.1:${QT_QPA_VNC_PORT:-5900}"
exec websockify --web="${NOVNC_WEB}" 0.0.0.0:6080 "127.0.0.1:${QT_QPA_VNC_PORT:-5900}"
+18 -2
View File
@@ -1,13 +1,29 @@
"""Entry point: ``python -m cowork_local``."""
"""Entry point: ``python -m cowork_local``.
Also works when run directly as ``python __main__.py`` — see main().
"""
from __future__ import annotations
import os
import sys
def main() -> int:
# Imported lazily so that ``-h`` style tooling and tests can import the
# package without spinning up a full Qt application.
from .app import run
#
# `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)
+623 -140
View File
@@ -7,18 +7,21 @@ from pathlib import Path
from typing import List
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QGuiApplication, QIcon
from PySide6.QtGui import QColor, QGuiApplication, QIcon
from PySide6.QtWidgets import (
QStyledItemDelegate,
QApplication, QComboBox, QHBoxLayout, QLabel, QMainWindow, QMenu,
QPushButton, QSizePolicy, QSplitter, QStackedWidget, QSystemTrayIcon,
QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget,
QPushButton, QScrollArea, QSizePolicy, QSplitter, QStackedWidget,
QSystemTrayIcon, QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout,
QWidget,
)
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 .ui.widgets import tidy_popup
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
@@ -35,6 +38,23 @@ ASSETS = Path(__file__).resolve().parent / "assets"
# shows icon-only (still fully clickable, just narrower).
_NAV_EXPANDED_WIDTH = 150
_NAV_COLLAPSED_WIDTH = 54
# The splitter between rail and content draws a drag handle. It only means
# something if the rail can actually take a width from it, so the expanded rail
# is a range rather than one number; long project and thread names in RECENTS
# are the reason someone would widen it.
#
# The ceiling is a SHARE of the window, not a pixel count: 360px is a quarter
# of a 1440 screen and more than a quarter of a 1280 one, where it left the
# seven Kanban lanes 920px of the 1067 they need. A share behaves the same on
# every monitor.
# Where a rail row starts, and how much air sits between its icon and its
# label. The tree rows get these from the style; anything laid out by hand
# beside them has to use the same two numbers or it will not line up.
_NAV_ROW_INSET = 4
_NAV_ROW_GAP = 6
_NAV_MIN_WIDTH = 132
_NAV_MAX_SHARE = 0.22
_NAV_MAX_CEILING = 360
def app_icon() -> QIcon:
@@ -64,10 +84,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
@@ -76,6 +98,23 @@ class _Toast(QLabel):
self._timer.start(ms)
class _NavItemDelegate(QStyledItemDelegate):
"""Keep a rail row's icon on the left edge, whatever the column is doing.
QStyledItemDelegate hands the style decorationAlignment = AlignHCenter, so
a row with no label — every row once the rail collapses to 54px — has its
icon centred inside whatever box the column happens to give it. That box
tracks the column width, which is not stable: stretched to the viewport the
icons land in the middle of the rail, while a column left wider than the
view leaves them at the left. Same code, two different pictures, which is
why a test render disagreed with the running app.
"""
def initStyleOption(self, option, index):
super().initStyleOption(option, index)
option.decorationAlignment = Qt.AlignLeft | Qt.AlignVCenter
class MainWindow(QMainWindow):
# Nav rows (Dashboard/Schedule/Monitoring are lazy; Workspace is the eager home page).
_ROW_DASHBOARD, _ROW_SCHEDULE, _ROW_WORKSPACE, _ROW_MONITORING = 0, 1, 2, 3
@@ -155,8 +194,6 @@ class MainWindow(QMainWindow):
("app.tab.workspace", "workspaces", None, self.workspace),
("app.tab.monitoring", "monitoring", self._build_monitoring, None),
]
# Container pages whose sub-tabs become expandable nav children.
self._nav_parents = {self._ROW_WORKSPACE, self._ROW_MONITORING}
self._page_widgets = [] # page index → widget (placeholder until lazily built)
self._built = []
for _key, _icon_name, _builder, widget in self._nav_defs:
@@ -165,48 +202,37 @@ class MainWindow(QMainWindow):
self._page_widgets.append(page)
self._built.append(widget is not None)
# Left nav rail as a parent→child accordion (Claude-style): the container
# pages (Workspaces, Monitoring) expand to list their sub-views as
# children, and their in-content tab strips are hidden — so the content
# area is as large as possible.
from .ui.icons import icon as _icon
self.nav = QTreeWidget()
self.nav.setObjectName("navrail")
self.nav.setHeaderHidden(True)
self.nav.setIndentation(14)
self.nav.setRootIsDecorated(True)
self.nav.setExpandsOnDoubleClick(False)
self._nav_items = [] # page index → top-level QTreeWidgetItem
for page, (key, icon_name, _b, _w) in enumerate(self._nav_defs):
it = QTreeWidgetItem([tr(key)])
it.setIcon(0, _icon(icon_name))
it.setData(0, Qt.UserRole, {"page": page, "sub": None,
"parent": page in self._nav_parents, "key": key})
# A container (Monitoring/Workspace) always shows the dropdown arrow —
# even before its page/children are lazily built — so it's obvious it
# holds multiple sub-views. It stays collapsed until first expanded.
if page in self._nav_parents:
it.setChildIndicatorPolicy(QTreeWidgetItem.ShowIndicator)
self.nav.addTopLevelItem(it)
self._nav_items.append(it)
# Left nav rail — ONE FLAT LIST, no accordion. Every screen the user
# works in is one click away: the Workspace sub-views are listed
# directly instead of hiding behind an expandable parent. The two
# occasional admin destinations sit in a second, bottom-pinned list.
#
# Monitoring is the exception that keeps its sub-views OUT of the rail:
# it has eight, which would double the rail's length for screens opened
# once a week. Its own tab strip is left visible instead (it was hidden
# while the rail carried its children), so all eight stay reachable.
self.nav = self._new_nav_tree("navrail")
self.nav_bottom = self._new_nav_tree("navrailBottom")
self._nav_building = False # guards the rebuild → select → rebuild loop
self.workspace.hide_tab_bar()
self._reload_nav_children(self._ROW_WORKSPACE) # Workspace is eager
self.workspace.subtabs_changed.connect(
lambda: self._reload_nav_children(self._ROW_WORKSPACE))
self.nav.currentItemChanged.connect(lambda cur, _prev: self._navigate(cur))
self.nav.itemClicked.connect(self._on_nav_click)
self.nav.itemExpanded.connect(self._on_nav_expanded) # build children lazily
self._rebuild_nav()
self.workspace.subtabs_changed.connect(self._rebuild_nav)
for tree in (self.nav, self.nav_bottom):
tree.currentItemChanged.connect(
lambda cur, _prev, t=tree: self._on_nav_current(t, cur))
rlay.addWidget(self.pages, 1)
# Nav rail wrapper: a small toggle button ABOVE the page list so the
# whole rail can collapse to icon-only (still fully clickable). Same
# collapse/expand chevron iconography as every other collapsible panel.
from .ui.icons import collapse_left_icon, collapse_right_icon
from .ui.icons import icon as _icon
self._collapse_left_icon = collapse_left_icon
self._collapse_right_icon = collapse_right_icon
self._nav_wrap = QWidget()
self._nav_wrap.setObjectName("navWrap")
self._nav_wrap.setFixedWidth(_NAV_EXPANDED_WIDTH)
self._nav_width = _NAV_EXPANDED_WIDTH # remembered across collapses
self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width())
nvl = QVBoxLayout(self._nav_wrap)
nvl.setContentsMargins(0, 0, 0, 0)
nvl.setSpacing(0)
@@ -228,7 +254,107 @@ class MainWindow(QMainWindow):
toggle_row.addWidget(self._nav_toggle_btn, 0, Qt.AlignLeft)
toggle_row.addStretch(1)
nvl.addLayout(toggle_row)
nvl.addWidget(self.nav, 1)
# Primary action at the top of the rail, with the project it will land
# in named right above it. Before, starting a chat in another project
# meant leaving Cowork → Project tab → click a row → come back.
self.nav_project = QComboBox()
self.nav_project.setObjectName("navProjectPick")
self.nav_project.setToolTip(tr("app.nav.project_pick"))
self.nav_project.currentIndexChanged.connect(self._on_rail_project_pick)
tidy_popup(self.nav_project)
self.nav_new_chat = QPushButton(tr("cowork.new_chat"))
self.nav_new_chat.setObjectName("navNewChatBtn")
self.nav_new_chat.setIcon(_icon("plus"))
self.nav_new_chat.setCursor(Qt.PointingHandCursor)
self.nav_new_chat.clicked.connect(self._on_rail_new_chat)
# At 54px the picker cannot show a name, but dropping it altogether left
# the collapsed rail with no way to change project at all. This stands in
# for it: same list, same handler, just the folder icon and a tooltip.
self.nav_project_btn = QToolButton()
self.nav_project_btn.setObjectName("navProjectPickMini")
self.nav_project_btn.setIcon(_icon("folder"))
self.nav_project_btn.setCursor(Qt.PointingHandCursor)
self.nav_project_btn.setPopupMode(QToolButton.InstantPopup)
self.nav_project_btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.nav_project_btn.setMenu(QMenu(self.nav_project_btn))
self.nav_project_btn.menu().aboutToShow.connect(self._fill_rail_project_menu)
self.nav_project_btn.setVisible(False)
head = QVBoxLayout()
head.setContentsMargins(6, 0, 6, 6)
head.setSpacing(6)
head.addWidget(self.nav_project)
head.addWidget(self.nav_project_btn)
head.addWidget(self.nav_new_chat)
nvl.addLayout(head)
self.workspace.project_selected.connect(self._sync_rail_project)
self.workspace.projects_changed.connect(self._sync_rail_project)
self._syncing_rail_project = False
self._sync_rail_project()
# The destinations and RECENTS scroll together; the bottom group, the
# Settings button and the account row stay pinned below them.
#
# Without this the rail simply ran out of room on a short window (a
# 1280×720 laptop leaves ~570px here): nav and the bottom group have
# fixed heights, so the squeeze fell entirely on RECENTS, and once that
# hit zero the layout drew the "GẦN ĐÂY" heading straight over the last
# nav row.
self._nav_scroll = QScrollArea()
self._nav_scroll.setObjectName("navScroll")
self._nav_scroll.setWidgetResizable(True)
self._nav_scroll.setFrameShape(QScrollArea.NoFrame)
self._nav_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scroll_body = QWidget()
sv = QVBoxLayout(scroll_body)
sv.setContentsMargins(0, 0, 0, 0)
sv.setSpacing(0)
sv.addWidget(self.nav, 0)
# RECENTS — the threads of the project named in the picker above, right
# where Claude puts them. A shortcut only: the full History panel (search,
# filters, pin, bulk delete, context menu) stays exactly where it is, and
# "all projects…" at the end of this list opens it.
self.nav_recents_hdr = QLabel(tr("app.nav.recents"))
self.nav_recents_hdr.setObjectName("navSectionHdr")
sv.addWidget(self.nav_recents_hdr)
self.nav_recents = self._new_nav_tree("navRecents")
self.nav_recents.itemClicked.connect(self._on_rail_recent)
sv.addWidget(self.nav_recents, 1)
# Collapsing hides RECENTS, and with it the only item carrying a stretch
# factor. A box layout with nothing left to expand centres what remains,
# so the destinations dropped ~300px down the rail — "thu gọn menu lại
# ra giữa". This spacer takes the slack instead, and takes none of it
# while RECENTS is visible (stretch 0 against its 1).
sv.addStretch(0)
self._nav_scroll.setWidget(scroll_body)
nvl.addWidget(self._nav_scroll, 1)
# Bottom-pinned group: the places you visit occasionally, kept out of the
# way of the ones you live in. A hairline (styled via #navrailBottom in
# theme.py) separates the two lists.
nvl.addWidget(self.nav_bottom, 0)
# Settings reads as one more row under Dashboard / Giám sát, so its icon
# and label must start exactly where theirs do. Letting QPushButton place
# them does not achieve that: the gap it leaves between icon and text is
# the platform style's, and on macOS it is visibly tighter than the tree
# rows above — a Windows-tuned nudge only moved the mismatch. So the row
# is laid out here, in the same two numbers the tree uses: 4px in, 6px
# between.
self._nav_settings_btn = QPushButton()
self._nav_settings_btn.setObjectName("navSettingsBtn")
self._nav_settings_btn.setFlat(True)
self._nav_settings_btn.setCursor(Qt.PointingHandCursor)
self._nav_settings_btn.clicked.connect(self._open_settings)
srow = QHBoxLayout(self._nav_settings_btn)
srow.setContentsMargins(_NAV_ROW_INSET, 6, 8, 6)
srow.setSpacing(_NAV_ROW_GAP)
self._nav_settings_icon = QLabel()
self._nav_settings_icon.setPixmap(_icon("settings").pixmap(16, 16))
self._nav_settings_icon.setFixedSize(16, 16)
self._nav_settings_text = QLabel(tr("app.settings"))
srow.addWidget(self._nav_settings_icon)
srow.addWidget(self._nav_settings_text)
srow.addStretch(1)
nvl.addWidget(self._nav_settings_btn)
self._account_row = self._build_account_row()
nvl.addWidget(self._account_row)
self.split = QSplitter(Qt.Horizontal)
self.split.addWidget(self._nav_wrap)
@@ -236,10 +362,12 @@ class MainWindow(QMainWindow):
self.split.setStretchFactor(0, 0)
self.split.setStretchFactor(1, 1)
self.split.setSizes([_NAV_EXPANDED_WIDTH, 1000])
self.split.splitterMoved.connect(self._on_split_moved)
self.setCentralWidget(self.split)
# Workspace = landing/home (expand it and select its first sub-view).
self._nav_items[self._ROW_WORKSPACE].setExpanded(True)
self.nav.setCurrentItem(self._nav_items[self._ROW_WORKSPACE])
# Landing stays Workspace ▸ Project, exactly as before. Go through _goto
# so the page is actually shown — selecting the row alone only moves the
# highlight (its signals are blocked to avoid rebuild loops).
self._goto(self._ROW_WORKSPACE, self.workspace.current_subtab())
self.toast = _Toast(self) # top-left "task done" popup
# Floating in-app Help assistant — a robot icon pinned bottom-right on
# every screen; expands into a small help-only chat (see
@@ -253,8 +381,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()
@@ -273,6 +401,11 @@ class MainWindow(QMainWindow):
def resizeEvent(self, event): # noqa: N802 - Qt override
super().resizeEvent(event)
# The rail's ceiling is a share of the window, so it moves with the
# window. Computed once at construction it was read off a not-yet-sized
# window and stuck at 162px on every monitor.
if getattr(self, "_nav_wrap", None) is not None and not self._nav_collapsed:
self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width())
# Keep the floating Help assistant pinned to the bottom-right corner.
if getattr(self, "help_agent", None) is not None:
self.help_agent.reposition()
@@ -280,8 +413,24 @@ class MainWindow(QMainWindow):
def showEvent(self, event): # noqa: N802 - Qt override
super().showEvent(event)
if getattr(self, "help_agent", None) is not None:
self._update_dock_guard()
self.help_agent.reposition()
self.help_agent.raise_()
# Build GraphRAG's browser view and first graph once the window is up
# and idle, so clicking GraphRAG does not sit on an empty view while
# both happen. 3s is after the first paint and any startup refresh.
if not getattr(self, "_graph_prewarmed", False):
self._graph_prewarmed = True
QTimer.singleShot(3000, self._prewarm_graph)
def _prewarm_graph(self) -> None:
view = getattr(self, "structure", None)
if view is None or not hasattr(view, "prewarm"):
return
try:
view.prewarm()
except Exception: # noqa: BLE001 — a warm-up must never break the app
pass
# ---- i18n ----------------------------------------------------------
def _retranslate(self) -> None:
@@ -368,12 +517,9 @@ class MainWindow(QMainWindow):
placeholder.deleteLater()
self._page_widgets[row] = real
self._built[row] = True
# Container pages: hide their in-content tab strip + list their sub-views
# as children in the nav rail now that the real widget exists.
if hasattr(real, "hide_tab_bar"):
real.hide_tab_bar()
if row in self._nav_parents:
self._reload_nav_children(row)
# Monitoring KEEPS its own tab strip: its eight sub-views live in the
# page, not in the rail. Workspace is the one that hides its strip,
# because the rail lists its sub-views directly.
def _page_index(self, widget) -> int:
if widget is self.workspace:
@@ -386,28 +532,311 @@ class MainWindow(QMainWindow):
return self._ROW_MONITORING
return self.pages.indexOf(widget)
# ---- nav rail collapse (icon-only) --------------------------------
# ---- flat nav rail -------------------------------------------------
def _new_nav_tree(self, name: str) -> QTreeWidget:
"""One flat, single-column list. No indentation and no expand arrows —
every row is a destination, nothing is a container."""
tree = QTreeWidget()
tree.setObjectName(name)
tree.setHeaderHidden(True)
tree.setIndentation(0)
tree.setRootIsDecorated(False)
tree.setUniformRowHeights(True)
# The column follows the viewport instead of the widest label. Left
# to size itself it stayed ~100px wide inside the 54px collapsed
# rail, so a horizontal scrollbar appeared and slid the icons out of
# the position they hold while the rail is open.
from PySide6.QtWidgets import QHeaderView
tree.header().setSectionResizeMode(0, QHeaderView.Stretch)
tree.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
tree.setItemDelegate(_NavItemDelegate(tree))
return tree
def _nav_rows(self):
"""(tree, page, sub, label, icon, enabled) for every row, rail order.
Workspace contributes all five of its sub-views — including the two the
project gate currently disables — so the rail never changes shape while
the user is looking at it.
"""
rows = [(self.nav, self._ROW_WORKSPACE, sub, label, ic, on)
for label, sub, ic, on in self.workspace.nav_entries()]
rows.append((self.nav, self._ROW_SCHEDULE, None,
tr("app.tab.schedule"), "schedule", True))
rows.append((self.nav_bottom, self._ROW_DASHBOARD, None,
tr("app.tab.dashboard"), "dashboard", True))
rows.append((self.nav_bottom, self._ROW_MONITORING, None,
tr("app.tab.monitoring"), "monitoring", True))
return rows
def _rebuild_nav(self, force: bool = False) -> None:
"""Re-fill both lists from _nav_rows(), keeping the current selection.
Rebuilding changes the current item, which would fire navigation and can
loop back here via subtabs_changed — hence the guard and the blocked
signals.
"""
if self._nav_building:
return
spec = self._nav_rows()
# Rebuilding deletes the QTreeWidgetItems, including the one a signal is
# currently being delivered for. subtabs_changed fires on every visit to
# Workspace, so skip the rebuild unless the rows really differ.
sig = [(label, page, sub, enabled)
for _t, page, sub, label, _ic, enabled in spec]
if not force and sig == getattr(self, "_nav_sig", None):
return
self._nav_sig = sig
self._nav_building = True
try:
from .ui.icons import icon as _icon
keep = self._current_nav_key()
for tree in (self.nav, self.nav_bottom):
blocked = tree.blockSignals(True)
tree.clear()
tree.blockSignals(blocked)
for tree, page, sub, label, icon_name, enabled in spec:
it = QTreeWidgetItem([""] if self._nav_collapsed else [label])
it.setIcon(0, _icon(icon_name))
it.setData(0, Qt.UserRole, {"page": page, "sub": sub})
if not enabled:
# Same gate as before, shown instead of hidden: the row stays
# in place, greyed, and says why it cannot be opened.
it.setDisabled(True)
it.setToolTip(0, tr("app.nav.needs_project"))
elif self._nav_collapsed:
it.setToolTip(0, label)
blocked = tree.blockSignals(True)
tree.addTopLevelItem(it)
tree.blockSignals(blocked)
# Both destination lists are exactly as tall as their rows; the
# stretch in between belongs to RECENTS.
for tree in (self.nav, self.nav_bottom):
n = tree.topLevelItemCount()
row_h = tree.sizeHintForRow(0) if n else 0
tree.setFixedHeight(n * row_h + 8)
if keep:
self._select_nav_row(*keep)
finally:
self._nav_building = False
def _current_nav_key(self):
"""(page, sub) of the highlighted row, or None."""
for tree in (self.nav, self.nav_bottom):
it = tree.currentItem()
if it is not None and it.isSelected():
data = it.data(0, Qt.UserRole) or {}
if "page" in data:
return data["page"], data.get("sub")
return None
def _select_nav_row(self, page: int, sub) -> None:
"""Highlight the row for (page, sub) without triggering navigation.
Called both when the user clicks (to keep the two lists mutually
exclusive) and from _goto, so programmatic navigation moves the
highlight too — it used to stay behind on whatever was clicked last.
"""
for tree in (self.nav, self.nav_bottom):
blocked = tree.blockSignals(True)
match = None
for i in range(tree.topLevelItemCount()):
it = tree.topLevelItem(i)
data = it.data(0, Qt.UserRole) or {}
if data.get("page") == page and (
data.get("sub") == sub or data.get("sub") is None):
match = it
break
if match is not None:
tree.setCurrentItem(match)
else:
tree.setCurrentItem(None)
tree.clearSelection()
tree.blockSignals(blocked)
def _on_nav_current(self, tree: QTreeWidget, item) -> None:
"""A row was picked: clear the other list so only one row looks active."""
if item is None or self._nav_building:
return
data = item.data(0, Qt.UserRole) or {}
other = self.nav_bottom if tree is self.nav else self.nav
blocked = other.blockSignals(True)
other.setCurrentItem(None)
other.clearSelection()
other.blockSignals(blocked)
self._goto(data.get("page", 0), data.get("sub"))
# ---- rail header: project picker + new chat ------------------------
def _sync_rail_project(self, *_a) -> None:
"""Mirror the workspace's project list/selection into the rail picker.
One-way on purpose: the project list stays the source of truth, this is
only a second place to see and change it.
"""
if self._syncing_rail_project:
return
self._syncing_rail_project = True
try:
choices = self.workspace.project_choices()
current = self.workspace.selected_project_id()
self.nav_project.clear()
for name, pid in choices:
self.nav_project.addItem(f"📁 {name}", pid)
if not choices:
# No project yet: say so, and say what to do about it, instead of
# leaving an empty box and a button that silently does nothing.
self.nav_project.addItem(tr("app.nav.no_project"), "")
idx = self.nav_project.findData(current)
if idx >= 0:
self.nav_project.setCurrentIndex(idx)
has = bool(choices)
tidy_popup(self.nav_project)
self.nav_project.setEnabled(has)
self.nav_project_btn.setEnabled(has)
self.nav_project_btn.setToolTip(
self.nav_project.currentText().replace("📁 ", "")
if has else tr("app.nav.create_project_first"))
self.nav_new_chat.setEnabled(has)
self.nav_new_chat.setToolTip(
"" if has else tr("app.nav.create_project_first"))
finally:
self._syncing_rail_project = False
def _fill_rail_project_menu(self) -> None:
"""Mirror the picker's items. Choosing one moves the picker, which runs
_on_rail_project_pick — the collapsed rail adds no second code path."""
menu = self.nav_project_btn.menu()
menu.clear()
for i in range(self.nav_project.count()):
act = menu.addAction(self.nav_project.itemText(i))
act.setCheckable(True)
act.setChecked(i == self.nav_project.currentIndex())
act.triggered.connect(
lambda _checked=False, row=i: self.nav_project.setCurrentIndex(row))
def _on_rail_project_pick(self, _idx: int) -> None:
if self._syncing_rail_project:
return
pid = self.nav_project.currentData()
if pid:
self.workspace.choose_project(pid)
# ---- rail RECENTS --------------------------------------------------
_RAIL_RECENTS = 5
def _refresh_rail_recents(self) -> None:
"""Re-fill the rail's recents from the active project's history."""
from .ui.icons import DOT_BLUE, dot_icon
from .ui.icons import icon as _icon
tree = self.nav_recents
blocked = tree.blockSignals(True)
tree.clear()
running = self._running_session_ids()
threads = self.workspace.recent_threads(self._RAIL_RECENTS)
for t in threads:
it = QTreeWidgetItem([t["title"]])
it.setToolTip(0, t["title"])
if t["session_id"] in running:
it.setIcon(0, dot_icon(DOT_BLUE)) # same marker as History
elif t["pinned"]:
it.setIcon(0, _icon("pin"))
it.setData(0, Qt.UserRole, {"path": t["path"], "kind": t["kind"]})
tree.addTopLevelItem(it)
if not threads:
it = QTreeWidgetItem([tr("sidebar.empty")])
it.setDisabled(True)
tree.addTopLevelItem(it)
# The way back to everything the rail cannot show — styled as a link
# (italic, accent-colored) so it reads as "go elsewhere", not another row.
more = QTreeWidgetItem([tr("app.nav.all_projects")])
more.setData(0, Qt.UserRole, {"all": True})
more_font = more.font(0)
more_font.setItalic(True)
more.setFont(0, more_font)
more.setForeground(0, QColor(current_palette().accent))
tree.addTopLevelItem(more)
tree.blockSignals(blocked)
self.nav_recents_hdr.setVisible(not self._nav_collapsed)
self.nav_recents.setVisible(not self._nav_collapsed)
def _on_rail_recent(self, item, _col: int = 0) -> None:
data = item.data(0, Qt.UserRole) or {}
if data.get("all"):
self._goto(self._ROW_WORKSPACE, self.workspace._cowork_tab_idx)
self.workspace.show_history_pane()
return
path = data.get("path")
if path:
self._goto(self._ROW_WORKSPACE, self.workspace._cowork_tab_idx)
self.workspace.open_thread(path, data.get("kind", "cowork"))
def _on_rail_new_chat(self) -> None:
"""Start a new chat, from any screen.
Same call the Cowork toolbar button makes — that button stays exactly
where it was; this is a second entry point, not a replacement.
"""
self._goto(self._ROW_WORKSPACE, None)
self.workspace.start_new_chat()
self._select_nav_row(self._ROW_WORKSPACE, self.workspace.current_subtab())
def _apply_nav_labels(self) -> None:
"""Set each top-level nav item's text for the current language AND
collapse state: collapsed shows icon-only (label → tooltip) and folds
the accordion so only the top-level icons show."""
for page, item in enumerate(self._nav_items):
key = self._nav_defs[page][0]
label = tr(key)
item.setText(0, "" if self._nav_collapsed else label)
item.setToolTip(0, label if self._nav_collapsed else "")
if self._nav_collapsed:
item.setExpanded(False)
# Refresh child labels (language-aware, from each container's tabText).
"""Re-label every row for the current language and collapse state
(collapsed = icon only, label moves to the tooltip)."""
# force: collapsing leaves the row spec identical, only the text changes.
self._rebuild_nav(force=True)
self._nav_settings_text.setText(tr("app.settings"))
self._nav_settings_text.setVisible(not self._nav_collapsed)
self._nav_settings_btn.setToolTip(tr("app.settings"))
# Collapsed to 54px there is no room for either control's label; the
# picker would be a stub of a name, so it steps aside entirely and the
# button keeps just its + icon.
self.nav_project.setVisible(not self._nav_collapsed)
self.nav_project_btn.setVisible(self._nav_collapsed)
self._refresh_rail_recents()
# Collapsed to 54px only the theme toggle still fits; the rest of the
# account row would be clipped, so it steps aside (Settings, which opens
# the same values in a dialog, stays reachable as an icon).
self.account_lbl.setVisible(not self._nav_collapsed)
self.language_combo.setVisible(not self._nav_collapsed)
self.provider_combo.setVisible(not self._nav_collapsed)
self.nav_new_chat.setText("" if self._nav_collapsed else tr("cowork.new_chat"))
if self._nav_new_chat_enabled():
self.nav_new_chat.setToolTip(
tr("cowork.new_chat") if self._nav_collapsed else "")
self._sync_rail_project()
def _nav_new_chat_enabled(self) -> bool:
return bool(self.workspace.project_choices())
def _nav_max_width(self) -> int:
"""The rail's ceiling for THIS window, as a share of it."""
return max(_NAV_MIN_WIDTH,
min(_NAV_MAX_CEILING, int(self.width() * _NAV_MAX_SHARE)))
def _set_nav_width_range(self, lo: int, hi: int) -> None:
"""setFixedWidth would leave the splitter handle inert — visible, and
doing nothing when dragged."""
self._nav_wrap.setMinimumWidth(lo)
self._nav_wrap.setMaximumWidth(hi)
def _on_split_moved(self, _pos: int, _index: int) -> None:
if not self._nav_collapsed:
for page in self._nav_parents:
if self._built[page]:
self._reload_nav_children(page)
self._nav_width = max(_NAV_MIN_WIDTH,
min(self._nav_max_width(), self._nav_wrap.width()))
def _toggle_nav(self) -> None:
if not self._nav_collapsed:
self._nav_width = max(_NAV_MIN_WIDTH,
min(self._nav_max_width(), self._nav_wrap.width()))
self._nav_collapsed = not self._nav_collapsed
width = _NAV_COLLAPSED_WIDTH if self._nav_collapsed else _NAV_EXPANDED_WIDTH
self._nav_wrap.setFixedWidth(width)
if self._nav_collapsed:
width = _NAV_COLLAPSED_WIDTH
self._set_nav_width_range(width, width)
else:
width = self._nav_width
self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width())
self._apply_nav_labels()
# Same chevron convention as every other collapsible panel: right-
# pointing (fill-right) means "click to expand", left means "collapse".
@@ -444,6 +873,7 @@ class MainWindow(QMainWindow):
current = self.cowork.session_id
self.sidebar.set_view_state(current, self._running_session_ids())
self.sidebar.refresh()
self._refresh_rail_recents() # the rail shortcut follows the panel
QTimer.singleShot(0, _do)
@@ -531,9 +961,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,22 +977,32 @@ 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)
# Provider / language / theme / Settings used to live here, five controls
# wide across the top of every screen. They are per-account settings, not
# per-screen ones, so they moved to the account row at the foot of the
# rail (_build_account_row) — same widgets, same handlers, new home.
return bar
self.provider_lbl = QLabel(tr("app.provider"))
self.provider_lbl.setObjectName("hint")
h.addWidget(self.provider_lbl)
self.provider_combo = QComboBox()
for key, label in PROVIDER_LABELS.items():
self.provider_combo.addItem(label, key)
idx = self.provider_combo.findData(self.ctx.config.active_provider)
if idx >= 0:
self.provider_combo.setCurrentIndex(idx)
self.provider_combo.currentIndexChanged.connect(self._on_provider_changed)
h.addWidget(self.provider_combo)
def _build_account_row(self) -> QWidget:
"""The rail's foot: who you are, and the settings that follow you.
Nothing new is introduced here — these are the exact widgets the top bar
used to hold, moved as-is so every existing signal still lands.
"""
box = QWidget()
box.setObjectName("navAccount")
v = QVBoxLayout(box)
v.setContentsMargins(6, 4, 6, 4)
v.setSpacing(4)
who = QHBoxLayout()
who.setSpacing(4)
self.account_lbl = QLabel(f"👤 {self._user_name}" if self._user_name else "👤")
self.account_lbl.setObjectName("hint")
who.addWidget(self.account_lbl, 1)
self.language_combo = QComboBox()
for key in LANGUAGES:
self.language_combo.addItem(LANGUAGE_SHORT.get(key, key.upper()), key)
@@ -572,22 +1011,28 @@ class MainWindow(QMainWindow):
idx = self.language_combo.findData(get_language())
if idx >= 0:
self.language_combo.setCurrentIndex(idx)
tidy_popup(self.language_combo)
self.language_combo.currentIndexChanged.connect(self._on_language_changed)
h.addWidget(self.language_combo)
who.addWidget(self.language_combo)
self.theme_btn = self._build_theme_button()
h.addWidget(self.theme_btn)
who.addWidget(self.theme_btn)
v.addLayout(who)
if self._user_name:
user_lbl = QLabel(f"👤 {self._user_name}")
user_lbl.setObjectName("hint")
h.addWidget(user_lbl)
self.settings_btn = QPushButton(tr("app.settings"))
from .ui.icons import icon as _icon
self.settings_btn.setIcon(_icon("settings"))
self.settings_btn.clicked.connect(self._open_settings)
h.addWidget(self.settings_btn)
return bar
self.provider_lbl = QLabel(tr("app.provider"))
self.provider_lbl.setObjectName("hint")
self.provider_lbl.setVisible(False) # the combo names itself in the rail
self.provider_combo = QComboBox()
self.provider_combo.setToolTip(tr("app.provider"))
for key, label in PROVIDER_LABELS.items():
self.provider_combo.addItem(label, key)
tidy_popup(self.provider_combo)
idx = self.provider_combo.findData(self.ctx.config.active_provider)
if idx >= 0:
self.provider_combo.setCurrentIndex(idx)
self.provider_combo.currentIndexChanged.connect(self._on_provider_changed)
v.addWidget(self.provider_lbl)
v.addWidget(self.provider_combo)
return box
_BRAND_LOGO_NAMES = ("fpt_logo.png", "fpt-logo.png", "logo_fpt.png", "fpt_logo.jpg")
_BRAND_LOGO_HEIGHT = 22
@@ -661,6 +1106,11 @@ class MainWindow(QMainWindow):
dlg = SettingsDialog(self.ctx, self)
if dlg.exec():
self._apply_theme()
# Settings can change the theme too — keep the rail's toggle icon
# showing the value that is actually in effect.
from .ui.icons import icon as _theme_icon
self.theme_btn.setIcon(
_theme_icon(self._THEME_ICONS.get(self.ctx.config.theme, "monitor")))
set_language(self.ctx.config.language) # apply if changed in Settings
# reflect provider/theme/language changes
i = self.provider_combo.findData(self.ctx.config.active_provider)
@@ -679,46 +1129,6 @@ class MainWindow(QMainWindow):
self.sidebar.refresh()
self.statusBar().showMessage(tr("app.status.settings_saved"))
def _reload_nav_children(self, page: int) -> None:
"""(Re)build the nav children of a container page from its current
sub-views. Called when the page is built, when Workspace sub-tab
visibility changes, and on language change."""
if not (0 <= page < len(self._nav_items)):
return
item = self._nav_items[page]
expanded = item.isExpanded()
item.takeChildren()
widget = self._page_widgets[page]
if not hasattr(widget, "nav_subtabs"):
return
from .ui.icons import icon as _icon
for label, sub, icon_name in widget.nav_subtabs():
child = QTreeWidgetItem([label])
child.setIcon(0, _icon(icon_name))
child.setData(0, Qt.UserRole, {"page": page, "sub": sub, "parent": False})
item.addChild(child)
item.setExpanded(True)
def _on_nav_click(self, item, _col: int = 0) -> None:
# Clicking a parent toggles its expansion (its page is still shown).
data = item.data(0, Qt.UserRole) or {}
if data.get("parent"):
item.setExpanded(not item.isExpanded())
def _on_nav_expanded(self, item) -> None:
# Expanding a container whose children aren't built yet (e.g. Monitoring
# on first open, shown via its always-on dropdown arrow) builds its page
# so the sub-views appear.
data = item.data(0, Qt.UserRole) or {}
if data.get("parent") and item.childCount() == 0:
self._ensure_page(data.get("page", 0))
def _navigate(self, item) -> None:
if item is None:
return
data = item.data(0, Qt.UserRole) or {}
self._goto(data.get("page", 0), data.get("sub"))
def _goto(self, page: int, sub) -> None:
self._ensure_page(page) # build lazy page on first visit
self.pages.setCurrentIndex(page)
@@ -726,10 +1136,52 @@ class MainWindow(QMainWindow):
self.workspace.refresh() # re-list projects + threads on entry
widget = self._page_widgets[page]
if sub is not None and hasattr(widget, "select_subtab"):
widget.select_subtab(sub)
# Enforce the project gate here rather than at each entry point. A
# greyed rail row cannot be clicked, but _goto is also reached from
# RECENTS and from startup restore, and it used to open a sub-tab
# the gate was holding shut — page shown, tab strip still hiding it.
if hasattr(widget, "subtab_available") and not widget.subtab_available(sub):
self.statusBar().showMessage(tr("app.nav.needs_project"), 4000)
else:
widget.select_subtab(sub)
# Move the highlight with the content, however navigation was triggered —
# a programmatic _goto used to leave it on whatever was clicked last.
if not self._nav_building:
self._select_nav_row(page, sub)
self._update_dock_guard()
# Switching pages updates which conversation is "current".
self._refresh_history()
def _update_dock_guard(self) -> None:
"""Keep the floating assistant clear of a screen's own bottom bar.
Only Cowork has one (the composer). Everywhere else the dock sits in
the corner as before.
"""
dock = getattr(self, "help_agent", None)
if dock is None:
return
guard = 0
on_cowork = (self.pages.currentIndex() == self._ROW_WORKSPACE
and self.workspace.current_subtab() == self.workspace._cowork_tab_idx)
if on_cowork:
comp = getattr(self.cowork, "composer", None)
if comp is not None and not comp.isHidden():
# Measured from the composer's TOP edge in window coordinates:
# its own height misses the extra row of controls laid out under
# it, which left the dot still overlapping by ~25px.
origin = comp.mapTo(self, comp.rect().topLeft())
# ...but only lift the dot if the composer is actually beneath
# it. The composer stops at the chat column's right edge, well
# short of the dot, so lifting it there raised the dot 156px for
# nothing — on Cowork alone it sat off the corner every other
# screen keeps it in.
dock_left = dock.x() - self.mapToGlobal(self.rect().topLeft()).x()
dock_right = dock_left + dock.width()
if dock_right > origin.x() and dock_left < origin.x() + comp.width():
guard = max(0, self.height() - origin.y() + 8)
dock.set_bottom_guard(guard)
def _on_projects_changed(self) -> None:
self.sidebar.refresh() # History regroups by project
self.cowork._apply_output_folder_label() # project may have been renamed
@@ -738,6 +1190,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()
@@ -745,6 +1198,11 @@ class MainWindow(QMainWindow):
self.help_agent.apply_theme() # chat body follows theme (header stays fixed)
# ---- sizing ------------------------------------------------------
# Share of the available screen the window takes when it has room to. Fixed
# pixels do not travel: 1180×760 fills a laptop and looks lost on a 4K
# panel. `want_*` stays the floor so a small screen behaves as before.
_SCREEN_SHARE_W, _SCREEN_SHARE_H = 0.80, 0.85
def _fit_to_screen(self, want_w: int, want_h: int) -> None:
screen = self.screen() or QGuiApplication.primaryScreen()
avail = screen.availableGeometry() if screen else None
@@ -752,8 +1210,12 @@ class MainWindow(QMainWindow):
self.resize(want_w, want_h)
return
margin = 60
w = min(want_w, avail.width() - margin)
h = min(want_h, avail.height() - margin)
# Take a share of the screen, never less than the asked-for size and
# never more than the screen can show.
w = min(max(want_w, int(avail.width() * self._SCREEN_SHARE_W)),
avail.width() - margin)
h = min(max(want_h, int(avail.height() * self._SCREEN_SHARE_H)),
avail.height() - margin)
# minimum must never exceed what the screen can show
self.setMinimumSize(min(820, avail.width() - margin), min(520, avail.height() - margin))
self.resize(max(w, 1), max(h, 1))
@@ -761,6 +1223,25 @@ class MainWindow(QMainWindow):
frame.moveCenter(avail.center())
self.move(frame.topLeft())
def moveEvent(self, event): # noqa: N802 - Qt override
super().moveEvent(event)
# Dragged to another monitor: its work area (and scaling) may differ, so
# the floating assistant re-pins and the panes re-decide if they fit.
self._on_screen_maybe_changed()
def _on_screen_maybe_changed(self) -> None:
screen = self.screen()
if screen is getattr(self, "_last_screen", None):
return
self._last_screen = screen
avail = screen.availableGeometry() if screen else None
if avail is not None:
self.setMinimumSize(min(820, avail.width() - 60),
min(520, avail.height() - 60))
if getattr(self, "help_agent", None) is not None:
self._update_dock_guard()
self.help_agent.reposition()
# ---- lifecycle ---------------------------------------------------
def closeEvent(self, event) -> None: # noqa: N802
keep = (self.tray is not None
@@ -843,6 +1324,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 +1340,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:
+12
View File
@@ -0,0 +1,12 @@
"""Application layer - pure Python use-case orchestration.
Sits between ``presentation/`` (Qt widgets) and ``domain/`` (entities). A module
here answers "what has to happen, in what order" for one use case - route a
turn, run a conversation - without knowing whether a human, a scheduler or a
test triggered it.
Hard rule (ADR-001 I1/I3, enforced by ``scripts/check_imports.py``): no
PySide6/PyQt imports and no reach into ``presentation/``/``ui/``. Results travel
back up through plain-Python callbacks; turning those into Qt signals is the
presentation layer's job.
"""
+8
View File
@@ -0,0 +1,8 @@
"""Conversation use case: the lifecycle of one agent turn (EPIC R04)."""
from .conversation_application_service import (
ConversationApplicationService,
TurnResult,
)
__all__ = ["ConversationApplicationService", "TurnResult"]
@@ -0,0 +1,328 @@
"""ConversationApplicationService - the turn lifecycle, outside the widget (R04-T03).
What this replaces
------------------
The lifecycle of one Cowork turn is currently spread across a closure inside
``ui/cowork_tab.py::build_job`` and a second, near-identical assembly inside
``core/task_executors.py::_run_agent``. Both:
* read live UI/config state from a worker thread,
* build the provider, the MCP tool set and the project context by hand,
* call ``core.chat_agent.run_cowork`` with a dozen positional-ish arguments,
* consume untyped event dicts.
Two copies means a fix to one path (say, promoting output files on failure)
silently misses the other. This service is the single implementation: it takes
an immutable :class:`ConversationExecutionRequest`, runs the turn, and reports
typed :class:`AgentEvent` objects.
What it deliberately does NOT do
--------------------------------
It does not re-implement the agent loop. ``run_cowork`` stays the engine
(strangler fig, ADR-001 section 4) and keeps its characterization tests
(``tests/characterization/test_run_cowork.py``). This layer owns the parts that
were tangled into the UI: assembling the call, translating events, and giving a
turn a well-defined end.
Pure Python: no Qt import, no config access. Everything it needs arrives through
constructor callbacks, so the same service runs a turn from a chat panel, from
the scheduler, or from a test.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
from cowork_local.domain.agents.agent_event import (
AgentEvent,
ErrorEvent,
TurnCompletedEvent,
collect_text,
event_from_dict,
)
from cowork_local.domain.agents.conversation_execution_request import (
ConversationExecutionRequest,
)
logger = logging.getLogger("cowork_local.conversations")
# Presentation/scheduler supplies these. Kept as plain callables (not objects)
# so a test can wire the service with three lambdas.
EventCallback = Callable[[AgentEvent], None]
CancelFn = Callable[[], bool]
ProviderFactory = Callable[[str, str], Any] # (provider_id, model) -> Provider
ToolSourceFactory = Callable[[], Tuple[Any, Any]] # () -> (extra_tools, extra_executor)
GateFactory = Callable[[ConversationExecutionRequest], Any] # -> PermissionGate or None
@dataclass
class TurnResult:
"""What a finished turn produced.
``messages`` is the conversation AFTER the turn (system prompt inserted,
assistant and tool messages appended) - the caller persists this as the new
history. ``final_text`` is the visible answer, reasoning excluded.
"""
request: ConversationExecutionRequest
messages: List[Dict[str, Any]] = field(default_factory=list)
events: List[AgentEvent] = field(default_factory=list)
final_text: str = ""
cancelled: bool = False
error: str = ""
# The original exception, kept alongside its message so a caller that needs
# to preserve legacy failure handling can re-raise the SAME object rather
# than a lookalike (SecurityBlocked, for instance, carries context that a
# re-wrapped RuntimeError would lose).
exception: Optional[BaseException] = None
@property
def ok(self) -> bool:
"""True when the turn completed without an error and without a Stop."""
return not self.error and not self.cancelled
def raise_if_failed(self) -> None:
"""Re-raise the turn's failure, if any.
Callers that already have failure handling built around an exception
(the Qt worker turns one into its ``failed`` signal) use this to keep
that path intact while still getting a TurnResult on success."""
if self.exception is not None:
raise self.exception
def output_dir(self) -> Optional[Path]:
"""This turn's output folder, or None when it could not write files."""
return Path(self.request.output_dir) if self.request.output_dir else None
class ConversationApplicationService:
"""Runs one agent turn from an immutable request.
Args:
provider_factory: ``(provider_id, model) -> Provider``. Production passes
``AppContext.build_provider_for``; tests pass a lambda returning a
:class:`FakeProvider`.
tool_source: ``() -> (extra_tools, extra_executor)`` for MCP/connector
tools. Optional - a turn with no external tools passes nothing.
gate_factory: ``(request) -> PermissionGate | None``, consulted when the
request asks to confirm commands. Optional for the same reason.
runner: the turn engine. Defaults to ``core.chat_agent.run_cowork``,
imported lazily so this module stays importable (and testable)
without pulling in the whole legacy tool stack.
security_config: the app config the security layers read. ``None``
disables them, which is what headless callers already rely on.
"""
def __init__(
self,
provider_factory: ProviderFactory,
*,
tool_source: Optional[ToolSourceFactory] = None,
gate_factory: Optional[GateFactory] = None,
runner: Optional[Callable[..., Any]] = None,
security_config: Any = None,
) -> None:
self._provider_factory = provider_factory
self._tool_source = tool_source
self._gate_factory = gate_factory
self._runner = runner
self._security_config = security_config
# -- main entry point -------------------------------------------------- #
def run_turn(
self,
request: ConversationExecutionRequest,
on_event: Optional[EventCallback] = None,
cancel: Optional[CancelFn] = None,
) -> TurnResult:
"""Execute one turn and return everything it produced.
Never raises: a provider or tool failure becomes an :class:`ErrorEvent`
plus ``TurnResult.error``. Callers run this on a worker thread and have
no good way to handle an exception crossing that boundary - today an
escaped error kills the worker and the UI just stops updating, with no
message shown.
Exactly one :class:`TurnCompletedEvent` is always emitted last, whether
the turn succeeded, failed or was cancelled. That is the end-of-turn
signal the legacy engine never had.
"""
return self.execute_turn(self.begin_turn(request), on_event=on_event, cancel=cancel)
def begin_turn(self, request: ConversationExecutionRequest) -> TurnResult:
"""Create the (still empty) result a turn will fill in.
Exposed separately from :meth:`run_turn` because some callers need the
LIVE message list while the turn is running, not only afterwards: the
scheduler re-saves the conversation to History after every assistant
message so a long unattended run shows live progress when reopened.
Handing them ``result.messages`` - the very list the engine appends to -
is what makes that possible without leaking the engine into the caller.
"""
return TurnResult(request=request, messages=request.message_list())
def execute_turn(
self,
result: TurnResult,
on_event: Optional[EventCallback] = None,
cancel: Optional[CancelFn] = None,
) -> TurnResult:
"""Run a turn previously created by :meth:`begin_turn`. See
:meth:`run_turn` for the error/cancellation contract."""
request = result.request
emit = self._make_emitter(result, on_event)
cancel = cancel or (lambda: False)
try:
self._execute(request, result, emit, cancel)
except Exception as exc: # noqa: BLE001 - see docstring
result.error = str(exc) or exc.__class__.__name__
result.exception = exc
logger.exception("turn %s failed", request.turn_id)
emit(ErrorEvent(message=result.error,
recoverable=self._is_recoverable(exc)))
result.cancelled = bool(cancel())
result.final_text = collect_text(result.events) or self._last_assistant_text(result.messages)
emit(TurnCompletedEvent(content=result.final_text, cancelled=result.cancelled))
return result
# -- internals --------------------------------------------------------- #
def _execute(self, request: ConversationExecutionRequest, result: TurnResult,
emit: Callable[[AgentEvent], None], cancel: CancelFn) -> None:
"""Assemble the engine call from the request snapshot and run it."""
provider = self._provider_factory(request.provider, request.model)
extra_tools, extra_executor = self._resolve_tools()
gate = self._resolve_gate(request)
# The engine speaks untyped dicts; bridge them into typed events at this
# single point rather than at every consumer.
def legacy_emit(payload: Dict[str, Any]) -> None:
event = event_from_dict(payload)
if event is not None:
emit(event)
run = self._resolve_runner()
run(
provider,
result.messages, # mutated in place by the engine, as before
self._output_dir(request),
legacy_emit,
cancel,
title=request.title,
extra_tools=extra_tools,
extra_executor=extra_executor,
project_context=request.project_context,
security_config=self._security_config,
gate=gate,
allowed_tools=list(request.allowed_tools) if request.allowed_tools is not None else None,
max_steps=request.max_steps,
run_to_completion=request.run_to_completion,
completion_max_steps=request.completion_max_steps,
enforce_rules=request.enforce_rules,
**self._role_kwargs(request),
)
@staticmethod
def _make_emitter(result: TurnResult,
on_event: Optional[EventCallback]) -> Callable[[AgentEvent], None]:
"""Record every event on the result AND forward it to the caller.
Recording is unconditional so a headless caller (the scheduler) can read
the full event list afterwards without having to supply a callback just
to collect it - which is exactly what task_executors does today with an
ad-hoc list.
"""
def emit(event: AgentEvent) -> None:
result.events.append(event)
if on_event is None:
return
try:
on_event(event)
except Exception: # noqa: BLE001
# A consumer that throws (a closing widget, say) must not abort
# the turn that is feeding it.
logger.debug("event consumer raised for %s", event.type, exc_info=True)
return emit
def _resolve_runner(self) -> Callable[..., Any]:
"""The turn engine, imported lazily on first use."""
if self._runner is None:
from cowork_local.core.chat_agent import run_cowork
self._runner = run_cowork
return self._runner
def _resolve_tools(self) -> Tuple[Any, Any]:
"""MCP/connector tools for this turn, or ``(None, None)``.
A failure here degrades to "no external tools" rather than failing the
turn: an MCP server that will not start must not stop the user from
chatting, which is the behaviour the chat panel already relies on.
"""
if self._tool_source is None:
return None, None
try:
return self._tool_source()
except Exception: # noqa: BLE001
logger.warning("tool source unavailable - running without external tools",
exc_info=True)
return None, None
def _resolve_gate(self, request: ConversationExecutionRequest) -> Any:
"""The permission gate, when this turn asked to confirm commands."""
if not request.confirm_commands or self._gate_factory is None:
return None
return self._gate_factory(request)
@staticmethod
def _output_dir(request: ConversationExecutionRequest) -> Path:
"""The turn's output folder as a Path.
The request holds it as a string to stay serialisable; converting at the
single point of use keeps that decision from leaking into every caller.
"""
return Path(request.output_dir) if request.output_dir else Path.cwd()
@staticmethod
def _role_kwargs(request: ConversationExecutionRequest) -> Dict[str, Any]:
"""``agent_role`` only when the request set one.
Omitted otherwise so the engine applies its own default (the interactive
Cowork role) instead of being handed an empty string, which would land
in the audit log as an unattributed tool call.
"""
return {"agent_role": request.agent_role} if request.agent_role else {}
@staticmethod
def _last_assistant_text(messages: List[Dict[str, Any]]) -> str:
"""Fallback answer text when no text events were seen.
A turn whose whole answer arrived in one non-streamed message still has
to report a final answer - the scheduler writes it into output.md, and
an empty string there reads as "(no output)".
"""
for message in reversed(messages):
if message.get("role") == "assistant" and (message.get("content") or "").strip():
return str(message["content"])
return ""
@staticmethod
def _is_recoverable(exc: Exception) -> bool:
"""Whether the user can act on this failure themselves.
"Model not found" is the motivating case: the chat panel restores the
typed message into the composer so the user can switch model and resend
instead of retyping it (see providers/base.py::MODEL_NOT_FOUND_HINT).
"""
try:
from cowork_local.providers.base import is_model_not_found_error
return bool(is_model_not_found_error(str(exc)))
except Exception: # noqa: BLE001
return False
__all__ = ["ConversationApplicationService", "TurnResult"]
+12
View File
@@ -0,0 +1,12 @@
"""Model routing use case: pick the best-fit model for one turn (EPIC R03)."""
from .routing_application_service import (
RoutingApplicationService,
RoutingDecision,
RoutingMode,
is_valid_mode,
normalize_mode,
)
__all__ = ["RoutingApplicationService", "RoutingDecision", "RoutingMode",
"normalize_mode", "is_valid_mode"]
@@ -0,0 +1,353 @@
"""RoutingApplicationService - one routing flow for every surface (R03-T03).
Before this service, the same routing algorithm existed three times:
* ``ui/chat_panel.py::_apply_routing`` (Cowork chat)
* ``ui/co4e_tab.py::_apply_co4e_routing`` (Co4E studio)
* ``ui/folder_tab.py::_ai_apply_routing`` (AI-Edit)
The three copies had already drifted - each one resolves the "current model"
differently and each one has its own private notion of what to do when the user
declines - and every one of them lives inside a Qt widget, so none of the logic
could be tested without building a window.
This module is the single implementation. It is pure Python: no Qt import, no
config access, no network. The presentation layer supplies a confirm callback
and renders the notice; everything else happens here.
Modes (:class:`RoutingMode`)
----------------------------
* ``OFF`` - never switch. The user's pinned model always wins.
* ``AUTO`` - switch silently when the best candidate clears the gain threshold.
* ``MANUAL`` - propose the switch and switch only if the confirm callback approves.
* ``FALLBACK`` - never switch pre-emptively; switch only AFTER the current model
fails, to the next-best candidate. This is the mode a user wants when they
trust their own model choice but still want the turn to survive an outage.
Migration note (ADR-001 section 4): the scoring/ranking engine is NOT rewritten.
This service depends on the small :class:`RoutingPort` interface, and production
wires the existing, already-tested ``core.routing.service.RoutingService`` into
it. Tests wire a fake.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Any, Callable, List, Optional, Protocol, Sequence, Tuple
class RoutingMode(str, Enum):
"""Per-surface routing behaviour.
The first three values match ``core.routing.models.SwitchMode`` string for
string, so a mode read from the existing config round-trips unchanged.
"""
OFF = "off"
AUTO = "auto"
MANUAL = "manual"
FALLBACK = "fallback"
@classmethod
def parse(cls, raw: Any) -> "RoutingMode":
"""Best-effort parse of a config value.
Unknown or empty values become ``OFF``: routing is an optimisation, and
the safe reading of a corrupt setting is "leave the user's model alone"
rather than "silently move their work to another model".
"""
try:
return cls(str(raw or "off").strip().lower())
except ValueError:
return cls.OFF
@dataclass(frozen=True)
class RoutingDecision:
"""The outcome of routing one turn - an immutable instruction for the caller.
``provider``/``model`` are ALWAYS filled with what the turn should actually
run on, switched or not, so a call site never has to re-derive the fallback
itself (the bug that made the three UI copies diverge).
"""
mode: RoutingMode
provider: str
model: str
switched: bool = False
task_type: str = ""
score_gain: float = 0.0
reason: str = ""
declined: bool = False # Manual mode: a switch was offered and refused
# What the turn would have run on without routing. Carried so the Manual
# confirm dialog can show "from X to Y" without re-deriving the current
# model itself - re-deriving it differently per screen is exactly how the
# three legacy copies drifted apart.
previous_provider: str = ""
previous_model: str = ""
@property
def should_notify(self) -> bool:
"""True when the UI should show the "switched model" notice - i.e. only
when a switch really happened."""
return self.switched
def target(self) -> Tuple[str, str]:
"""``(provider, model)`` to run this turn on."""
return self.provider, self.model
@property
def from_model(self) -> str:
"""Candidate key (``provider/model``) of the model being switched away
from, or "" when nothing was selected yet.
Named to match ``core.routing.models.SwitchDecision`` so the existing
Manual-mode dialog (``ui/routing_toggle.py::confirm_switch``) accepts
this object unchanged - the dialog moves to the new shape in EPIC R08.
"""
if not self.previous_model:
return ""
return f"{self.previous_provider}/{self.previous_model}"
@property
def to_model(self) -> str:
"""Candidate key (``provider/model``) of the model to run on. See
:attr:`from_model` for why the name matches the legacy decision."""
return f"{self.provider}/{self.model}" if self.model else ""
def is_valid_mode(raw: Any) -> bool:
"""True when ``raw`` names a mode the routing service understands.
Distinct from :func:`normalize_mode` because callers need to tell "the user
chose off" apart from "this stored value is unrecognised" - the per-workspace
lookup falls back to the global setting only in the second case.
"""
try:
RoutingMode(str(raw or "").strip().lower())
except ValueError:
return False
return True
def normalize_mode(raw: Any) -> str:
"""Canonical mode string for persistence, or ``"off"`` when unrecognised.
Exists so the mode vocabulary is defined exactly once. It used to be
hard-coded as a ``("off", "auto", "manual")`` tuple in four separate places
(config.py twice, state.py twice); adding FALLBACK meant finding all four,
and missing one silently downgraded the user's choice back to "off".
"""
return RoutingMode.parse(raw).value
class RoutingPort(Protocol):
"""The slice of the routing engine this service needs.
Declared as a Protocol so the application layer states its requirement
without importing the implementation - which is what lets the whole service
be tested against a 20-line fake, and lets ``core.routing`` be replaced later
without touching this file.
"""
def route(self, surface: str, prompt: str, current_provider: str, current_model: str,
*, mode_override: Optional[str] = None,
required_capabilities: Optional[List[str]] = None,
task_type: Optional[Any] = None) -> Any:
"""Return a route result exposing ``should_switch``, ``target()``,
``task_type`` and ``decision``."""
# Presentation supplies this to ask the human. Receives the proposal so the
# dialog can explain it; returns True to approve. Manual mode only.
ConfirmFn = Callable[[RoutingDecision], bool]
class RoutingApplicationService:
"""Decides which provider/model one turn runs on.
Args:
router: the scoring engine (see :class:`RoutingPort`).
mode_reader: ``surface -> mode string``; production passes the per-workspace
lookup ``AppContext.project_routing_mode``. Injected rather than read
from config here so this layer stays free of config plumbing that
EPIC R02 is rewriting in parallel.
"""
def __init__(self, router: RoutingPort,
mode_reader: Optional[Callable[[str], str]] = None) -> None:
self._router = router
self._mode_reader = mode_reader
# -- main entry point -------------------------------------------------- #
def route_turn(
self,
surface: str,
prompt: str,
current_provider: str,
current_model: str,
*,
mode: Optional[str] = None,
confirm: Optional[ConfirmFn] = None,
required_capabilities: Optional[Sequence[str]] = None,
task_type: Optional[Any] = None,
) -> RoutingDecision:
"""Decide what to run this turn on. Never raises.
A routing failure must never block a message: any unexpected error
degrades to "keep the current model", which is exactly what all three
legacy copies did with a bare ``except`` - made explicit and testable here.
"""
resolved_mode = RoutingMode.parse(mode if mode is not None else self._read_mode(surface))
keep = self._keep(resolved_mode, current_provider, current_model,
reason="routing off - keeping current model")
# An empty prompt carries no signal to classify, so routing cannot make a
# meaningful choice; the same guard exists in all three legacy copies.
if resolved_mode is RoutingMode.OFF or not (prompt or "").strip():
return keep
# FALLBACK never switches up front - it only reacts to a failure, which
# the caller reports through fallback_after_failure().
if resolved_mode is RoutingMode.FALLBACK:
return self._keep(resolved_mode, current_provider, current_model,
reason="fallback mode - switching only after a failure")
try:
result = self._router.route(
surface, prompt, current_provider, current_model,
mode_override=resolved_mode.value,
required_capabilities=list(required_capabilities) if required_capabilities else None,
task_type=task_type,
)
except Exception: # noqa: BLE001 - routing must never break a turn
return self._keep(resolved_mode, current_provider, current_model,
reason="routing engine failed - keeping current model")
proposal = self._to_decision(result, resolved_mode, current_provider, current_model)
if not proposal.switched:
return proposal
# Manual mode: the proposal only becomes a switch once a human approves.
if resolved_mode is RoutingMode.MANUAL:
if confirm is None or not self._ask(confirm, proposal):
return self._keep(resolved_mode, current_provider, current_model,
reason="switch declined - keeping current model",
task_type=proposal.task_type, declined=True)
return proposal
# -- failure recovery -------------------------------------------------- #
def fallback_after_failure(
self,
surface: str,
prompt: str,
failed_provider: str,
failed_model: str,
*,
mode: Optional[str] = None,
required_capabilities: Optional[Sequence[str]] = None,
task_type: Optional[Any] = None,
) -> Optional[RoutingDecision]:
"""Pick a replacement after ``failed_provider/failed_model`` failed.
Returns None when there is nothing to fall back to, so the caller can
surface the original error instead of retrying forever. Available in
AUTO and FALLBACK; OFF and MANUAL keep the user's model on failure too,
because silently moving work to another model is exactly what those two
modes exist to prevent.
"""
resolved_mode = RoutingMode.parse(mode if mode is not None else self._read_mode(surface))
if resolved_mode not in (RoutingMode.AUTO, RoutingMode.FALLBACK):
return None
try:
# Asked in AUTO so the engine ranks candidates rather than short-
# circuiting on FALLBACK's "never switch up front" rule; the failed
# model is passed as current so any positive gain beats it.
result = self._router.route(
surface, prompt, failed_provider, failed_model,
mode_override=RoutingMode.AUTO.value,
required_capabilities=list(required_capabilities) if required_capabilities else None,
task_type=task_type,
)
except Exception: # noqa: BLE001 - a broken router must not mask the real error
return None
decision = self._to_decision(result, resolved_mode, failed_provider, failed_model)
# A "switch" back to the model that just failed would retry the outage.
if not decision.switched or (decision.provider, decision.model) == (failed_provider, failed_model):
return None
return RoutingDecision(
mode=resolved_mode, provider=decision.provider, model=decision.model,
switched=True, task_type=decision.task_type, score_gain=decision.score_gain,
reason=f"{failed_provider}/{failed_model} failed - falling back to "
f"{decision.provider}/{decision.model}",
previous_provider=failed_provider, previous_model=failed_model,
)
# -- internals --------------------------------------------------------- #
def _read_mode(self, surface: str) -> str:
"""Per-surface mode from the injected reader ('off' when none supplied)."""
if self._mode_reader is None:
return RoutingMode.OFF.value
try:
return self._mode_reader(surface) or RoutingMode.OFF.value
except Exception: # noqa: BLE001 - a config read must not break a turn
return RoutingMode.OFF.value
@staticmethod
def _keep(mode: RoutingMode, provider: str, model: str, *, reason: str,
task_type: str = "", declined: bool = False) -> RoutingDecision:
"""A no-switch decision that still names the model to run on."""
return RoutingDecision(mode=mode, provider=provider, model=model, switched=False,
task_type=task_type, reason=reason, declined=declined,
previous_provider=provider, previous_model=model)
@staticmethod
def _ask(confirm: ConfirmFn, proposal: RoutingDecision) -> bool:
"""Run the confirm callback, treating any failure as "declined".
The callback opens a modal dialog in production; if that raises (window
already closing, for instance) the safe answer is to keep the user's own
model rather than to switch without consent.
"""
try:
return bool(confirm(proposal))
except Exception: # noqa: BLE001
return False
@staticmethod
def _to_decision(result: Any, mode: RoutingMode,
current_provider: str, current_model: str) -> RoutingDecision:
"""Translate the engine's route result into a :class:`RoutingDecision`.
Defensive about the result shape on purpose: this is the seam between the
new layer and a legacy module still under refactor, and a missing
attribute must degrade to "keep current model" instead of raising into
the middle of a chat turn.
"""
inner = getattr(result, "decision", None)
task_type = getattr(getattr(result, "task_type", None), "value", "") or ""
gain = float(getattr(inner, "score_gain", 0.0) or 0.0)
reason = str(getattr(inner, "reason", "") or "")
target = None
if getattr(result, "should_switch", False):
getter = getattr(result, "target", None)
target = getter() if callable(getter) else None
if not target:
return RoutingDecision(mode=mode, provider=current_provider, model=current_model,
switched=False, task_type=task_type, score_gain=gain,
reason=reason or "no better model - keeping current",
previous_provider=current_provider,
previous_model=current_model)
provider, model = target
return RoutingDecision(mode=mode, provider=provider or current_provider, model=model,
switched=True, task_type=task_type, score_gain=gain, reason=reason,
previous_provider=current_provider, previous_model=current_model)
__all__ = ["RoutingApplicationService", "RoutingDecision", "RoutingMode",
"RoutingPort", "normalize_mode", "is_valid_mode"]
+16 -16
View File
@@ -105,8 +105,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
# sandboxes agent-run shell commands) — reading a URL for info is safe and
# useful, so this defaults ON. Toggle in Settings → Security.
"allow_url_fetch": True,
# Set with COWORK_SANDBOX_PASSWORD. Never ship a shared unlock secret.
"sandbox_pw": "",
"sandbox_pw": "quandh14", # default password to unlock sandbox settings
"rulebase_path": "", # custom RULEBASE.md — attached to every agent execution
},
# Legacy generic-MCP-server list. MERGED into ext_connectors["other"] as of
@@ -174,8 +173,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
# Microsoft. Real Outlook/Teams/OneDrive/SharePoint access still requires a
# proper OAuth sign-in (not implemented yet) using tenant_id/client_id below.
"ms365": {
# Set with COWORK_MS365_UNLOCK_CODE. Never ship a shared unlock secret.
"unlock_code": "",
"unlock_code": "quandh14",
"unlocked": False, # runtime-only — never persisted as True, see save()
# Auto-connect MS365/OneDrive/SharePoint: the built-in MS365 MCP server
# launches automatically once the user is signed in (OAuth tenant/client
@@ -296,10 +294,6 @@ def _apply_env_overrides(data: Dict[str, Any]) -> Dict[str, Any]:
data["active_provider"] = os.environ["COWORK_ACTIVE_PROVIDER"]
if os.getenv("COWORK_CA_BUNDLE"):
data["tls_ca_bundle"] = os.environ["COWORK_CA_BUNDLE"]
if os.getenv("COWORK_SANDBOX_PASSWORD"):
data["agent_security"]["sandbox_pw"] = os.environ["COWORK_SANDBOX_PASSWORD"]
if os.getenv("COWORK_MS365_UNLOCK_CODE"):
data["ms365"]["unlock_code"] = os.environ["COWORK_MS365_UNLOCK_CODE"]
return data
@@ -558,19 +552,25 @@ class AppConfig:
return d
def routing_mode_for(self, surface: str) -> str:
"""Effective Off/Auto/Manual mode for a chat surface.
"""Effective Off/Auto/Manual/Fallback mode for a chat surface.
A per-surface override wins; an empty override falls back to the global
``switch_mode``. The value is validated through
``application.model_routing.normalize_mode`` so the accepted vocabulary
is defined in exactly one place (R03-T03) - it used to be a literal
tuple repeated here and in state.py, and adding a mode to one copy but
not the others silently downgraded the user's choice to "off"."""
from .application.model_routing import normalize_mode
A per-surface override ("auto"/"manual"/"off") wins; an empty override
falls back to the global ``switch_mode``."""
routing = self.routing
override = (routing.get("surface_modes", {}) or {}).get(surface, "")
mode = override or routing.get("switch_mode", "off")
return mode if mode in ("off", "auto", "manual") else "off"
return normalize_mode(override or routing.get("switch_mode", "off"))
def set_routing_mode_for(self, surface: str, mode: str) -> None:
"""Persist a chat surface's Off/Auto/Manual toggle selection."""
mode = mode if mode in ("off", "auto", "manual") else "off"
self.routing.setdefault("surface_modes", {})[surface] = mode
"""Persist a chat surface's routing toggle selection."""
from .application.model_routing import normalize_mode
self.routing.setdefault("surface_modes", {})[surface] = normalize_mode(mode)
self.save()
@property
+38 -6
View File
@@ -248,9 +248,34 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
"'error' (not silently skip it) if it genuinely can't be completed.\n\n"
f"{prompt}"
)
messages = [{"role": "user", "content": prompt}]
# One immutable snapshot of this run, then the shared turn service (R04-T05).
# The Schedule Task path used to assemble the run_cowork call itself, in
# parallel with ui/cowork_tab.py doing the same thing slightly differently -
# so a fix to one path silently missed the other. Both now go through
# ConversationApplicationService.
from ..application.conversations import ConversationApplicationService
from ..domain.agents import ConversationExecutionRequest
session_id = new_session_id()
project_id = project.project_id if project is not None else ""
project_context = projects.project_context_text(project)
conversation_service = ConversationApplicationService(
# The provider was already resolved above (admin agent / per-task
# override / machine default), so the factory just hands it back.
lambda _provider_id, _model: provider,
security_config=ctx.config,
)
turn = conversation_service.begin_turn(ConversationExecutionRequest.create(
prompt, [{"role": "user", "content": prompt}],
output_dir=str(out_dir), session_id=session_id, surface="task",
title=title, project_id=project_id, project_context=project_context,
# Tags every tool call in the audit log as a scheduled task rather than
# as the interactive Cowork tab.
agent_role=agent_roles.TASK,
))
# The LIVE list the engine appends to - History is re-saved from it after
# every assistant message so a long run shows progress when reopened.
messages = turn.messages
_save_history_session(ctx, task_type, title, messages, session_id, project_id)
# Tell the scheduler the session now genuinely EXISTS on disk — it
# refreshes History on this, not on the earlier "task_started" signal
@@ -269,14 +294,21 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
elif ev.get("type") == "plan_set":
last_plan_steps[:] = ev.get("steps") or []
project_context = projects.project_context_text(project)
watched_cancel, timed_out = _cancel_with_timeout(cancel, timeout_sec)
try:
if task_type == "cowork":
from .chat_agent import run_cowork
run_cowork(provider, messages, out_dir, emit_and_autosave, watched_cancel,
security_config=ctx.config, agent_role=agent_roles.TASK,
project_context=project_context)
# Typed events are rendered back into the legacy dict shape this
# module's autosave/plan tracking already consumes; it moves to
# AgentEvent directly once the scheduler UI migrates (EPIC R07/R08).
result = conversation_service.execute_turn(
turn,
on_event=lambda event: emit_and_autosave(event.to_dict()),
cancel=watched_cancel,
)
# This module's callers handle a failed run through an exception
# (execute_task writes error.txt from it), so re-raise the ORIGINAL
# error rather than reporting a silently empty answer.
result.raise_if_failed()
else:
from .code_agent import run_code
limits, block_network = agent_security.sandbox_settings(ctx.config)
Binary file not shown.
+327
View File
@@ -0,0 +1,327 @@
<!doctype html>
<html lang="vi" data-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cấu trúc hệ thống · Cowork Local</title>
<style>
:root{
--bg:#f6f7f9; --surface:#ffffff; --surface-2:#eef1f4; --border:#dbe0e7;
--ink:#131a22; --ink-2:#4b5663; --ink-3:#7c8794;
--accent:#0e7c86; --accent-ink:#0a5b63; --accent-soft:#e2f2f2;
--ok:#157f4a; --warn:#9a5a0e; --crit:#b5322c;
--ok-soft:#e4f4ea; --warn-soft:#f8efdd; --crit-soft:#f7e5e3;
--mono-bg:#eef2f6; --shadow:0 1px 2px rgba(16,24,32,.06),0 8px 24px -12px rgba(16,24,32,.18);
--font-display:"Segoe UI Variable Display","Segoe UI Semibold","Segoe UI",system-ui,-apple-system,sans-serif;
--font-body:"Segoe UI Variable Text","Segoe UI",system-ui,-apple-system,sans-serif;
--font-mono:"Cascadia Code","Cascadia Mono",Consolas,"SF Mono",ui-monospace,monospace;
--maxw:920px;
}
@media (prefers-color-scheme:dark){
:root{
--bg:#0d1218; --surface:#141c25; --surface-2:#1b2530; --border:#28343f;
--ink:#e7edf4; --ink-2:#a2b2c2; --ink-3:#6c7d8e;
--accent:#46cfc8; --accent-ink:#8fe9e3; --accent-soft:#14312f;
--ok:#47c97e; --warn:#e0a33a; --crit:#f0726b;
--ok-soft:#12281c; --warn-soft:#2c2410; --crit-soft:#2e1614;
--mono-bg:#0f1720; --shadow:0 1px 2px rgba(0,0,0,.4),0 10px 30px -16px rgba(0,0,0,.7);
}
}
:root[data-theme="light"]{
--bg:#f6f7f9; --surface:#ffffff; --surface-2:#eef1f4; --border:#dbe0e7;
--ink:#131a22; --ink-2:#4b5663; --ink-3:#7c8794;
--accent:#0e7c86; --accent-ink:#0a5b63; --accent-soft:#e2f2f2;
--ok:#157f4a; --warn:#9a5a0e; --crit:#b5322c;
--ok-soft:#e4f4ea; --warn-soft:#f8efdd; --crit-soft:#f7e5e3; --mono-bg:#eef2f6;
--shadow:0 1px 2px rgba(16,24,32,.06),0 8px 24px -12px rgba(16,24,32,.18);
}
:root[data-theme="dark"]{
--bg:#0d1218; --surface:#141c25; --surface-2:#1b2530; --border:#28343f;
--ink:#e7edf4; --ink-2:#a2b2c2; --ink-3:#6c7d8e;
--accent:#46cfc8; --accent-ink:#8fe9e3; --accent-soft:#14312f;
--ok:#47c97e; --warn:#e0a33a; --crit:#f0726b;
--ok-soft:#12281c; --warn-soft:#2c2410; --crit-soft:#2e1614; --mono-bg:#0f1720;
--shadow:0 1px 2px rgba(0,0,0,.4),0 10px 30px -16px rgba(0,0,0,.7);
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
@media (prefers-reduced-motion:reduce){html{scroll-behavior:auto}}
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--font-body);
font-size:16.5px;line-height:1.62;-webkit-font-smoothing:antialiased;}
.wrap{max-width:var(--maxw);margin:0 auto;padding:0 24px}
/* top bar */
header.top{position:sticky;top:0;z-index:20;background:color-mix(in srgb,var(--surface) 88%,transparent);
backdrop-filter:saturate(1.4) blur(10px);border-bottom:1px solid var(--border)}
.top .wrap{display:flex;align-items:center;gap:18px;height:58px}
.brand{font-family:var(--font-mono);font-weight:600;font-size:14px;letter-spacing:.02em;
color:var(--ink);display:flex;align-items:center;gap:9px;white-space:nowrap}
.brand .dot{width:10px;height:10px;border-radius:2px;background:var(--accent);
box-shadow:0 0 0 3px var(--accent-soft)}
nav.doc{display:flex;gap:4px;margin-left:auto;flex-wrap:wrap}
nav.doc a{font-size:13.5px;color:var(--ink-2);text-decoration:none;padding:7px 12px;border-radius:8px;
font-weight:550;white-space:nowrap}
nav.doc a:hover{background:var(--surface-2);color:var(--ink)}
nav.doc a[aria-current="page"]{background:var(--accent-soft);color:var(--accent-ink)}
.toggle{border:1px solid var(--border);background:var(--surface);color:var(--ink-2);
width:36px;height:34px;border-radius:9px;cursor:pointer;font-size:15px;display:grid;place-items:center}
.toggle:hover{color:var(--ink);border-color:var(--accent)}
.toggle:focus-visible,nav.doc a:focus-visible,a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
/* hero */
.hero{padding:64px 0 34px}
.eyebrow{font-family:var(--font-mono);font-size:12px;letter-spacing:.18em;text-transform:uppercase;
color:var(--accent-ink);font-weight:600;margin:0 0 14px}
h1{font-family:var(--font-display);font-weight:700;font-size:clamp(2.1rem,5vw,3.1rem);line-height:1.06;
letter-spacing:-.02em;margin:0 0 18px;text-wrap:balance}
.lead{font-size:1.16rem;color:var(--ink-2);max-width:64ch;margin:0}
.meta{display:flex;gap:10px;flex-wrap:wrap;margin-top:26px}
.tag{font-family:var(--font-mono);font-size:12px;padding:5px 11px;border-radius:999px;
background:var(--surface-2);border:1px solid var(--border);color:var(--ink-2)}
section{padding:34px 0;border-top:1px solid var(--border)}
h2{font-family:var(--font-display);font-weight:650;font-size:1.6rem;letter-spacing:-.01em;margin:0 0 6px;
display:flex;align-items:baseline;gap:12px;text-wrap:balance}
h2 .num{font-family:var(--font-mono);font-size:.85rem;color:var(--accent-ink);font-weight:600}
h3{font-family:var(--font-display);font-weight:600;font-size:1.13rem;margin:26px 0 8px;letter-spacing:-.01em}
p{margin:.6em 0}
.sub{color:var(--ink-2);margin:2px 0 20px;max-width:66ch}
a{color:var(--accent-ink);text-underline-offset:3px}
strong{font-weight:650;color:var(--ink)}
code,.k{font-family:var(--font-mono);font-size:.86em;background:var(--mono-bg);
padding:2px 6px;border-radius:6px;border:1px solid var(--border);color:var(--ink)}
/* layer stack diagram */
.stack{display:flex;flex-direction:column;gap:0;margin:24px 0}
.layer{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:16px 18px;
box-shadow:var(--shadow);position:relative}
.layer + .layer{margin-top:26px}
.layer + .layer::before{content:"";position:absolute;top:-20px;left:50%;width:2px;height:14px;
background:var(--border);transform:translateX(-50%)}
.layer + .layer::after{content:"▾";position:absolute;top:-14px;left:50%;transform:translateX(-50%);
color:var(--ink-3);font-size:13px}
.layer .lh{display:flex;align-items:center;gap:10px;margin-bottom:12px}
.layer .lh .tier{font-family:var(--font-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;
color:#fff;background:var(--accent);padding:3px 8px;border-radius:6px;font-weight:600}
.layer .lh h4{margin:0;font-family:var(--font-display);font-weight:600;font-size:1.02rem}
.layer .lh small{color:var(--ink-3);margin-left:auto;font-size:12.5px}
.chips{display:flex;flex-wrap:wrap;gap:8px}
.chip{font-family:var(--font-mono);font-size:12.5px;background:var(--surface-2);border:1px solid var(--border);
border-radius:8px;padding:6px 10px;color:var(--ink-2)}
.chip b{color:var(--ink);font-weight:600}
/* cards */
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:16px;margin-top:8px}
.card{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:18px 18px;
box-shadow:var(--shadow)}
.card h4{margin:0 0 6px;font-family:var(--font-display);font-size:1.04rem;font-weight:600;
display:flex;align-items:center;gap:9px}
.card h4 .ic{width:26px;height:26px;border-radius:7px;background:var(--accent-soft);color:var(--accent-ink);
display:grid;place-items:center;font-size:14px;flex:none}
.card p{margin:0;color:var(--ink-2);font-size:14.5px}
.card .files{margin-top:10px;display:flex;flex-wrap:wrap;gap:6px}
.card .files code{font-size:11.5px}
/* flow steps */
.flow{display:flex;flex-direction:column;gap:10px;margin:18px 0;counter-reset:fl}
.flow li{list-style:none;display:flex;gap:14px;align-items:flex-start;background:var(--surface);
border:1px solid var(--border);border-radius:12px;padding:13px 15px}
.flow li::before{counter-increment:fl;content:counter(fl);font-family:var(--font-mono);font-weight:600;
font-size:13px;color:var(--accent-ink);background:var(--accent-soft);border-radius:8px;
width:28px;height:28px;display:grid;place-items:center;flex:none}
.flow b{color:var(--ink)}
.flow small{color:var(--ink-3);display:block;font-size:13px}
.note{border:1px solid var(--border);border-left:3px solid var(--accent);background:var(--surface);
border-radius:0 12px 12px 0;padding:14px 16px;margin:18px 0;font-size:14.5px;color:var(--ink-2)}
.note b{color:var(--ink)}
footer{border-top:1px solid var(--border);padding:34px 0 60px;color:var(--ink-3);font-size:13.5px}
footer .wrap{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;align-items:center}
footer a{color:var(--ink-2)}
@media (max-width:560px){.brand span.full{display:none}.hero{padding:40px 0 24px}}
</style>
</head>
<body>
<header class="top"><div class="wrap">
<span class="brand"><span class="dot"></span>cowork_local<span class="full">&nbsp;· docs</span></span>
<nav class="doc" aria-label="Tài liệu">
<a href="architecture.html" aria-current="page">Cấu trúc</a>
<a href="security.html">Bảo mật</a>
<a href="usage.html">Cách dùng</a>
</nav>
<button class="toggle" id="themeBtn" title="Đổi giao diện sáng/tối" aria-label="Đổi giao diện">◐</button>
</div></header>
<main>
<div class="wrap hero">
<p class="eyebrow">Cowork Local · Tài liệu kỹ thuật</p>
<h1>Cấu trúc hệ thống</h1>
<p class="lead">Trợ lý AI dạng agent chạy <strong>cục bộ trên máy</strong> (desktop, ưu tiên Windows). Người dùng trò chuyện, chạy luồng nhiều bước, thao tác tệp và lên lịch tác vụ — mọi thứ được bọc trong một khung bảo mật nhiều lớp.</p>
<div class="meta">
<span class="tag">PySide6 / Qt6</span>
<span class="tag">Local-first</span>
<span class="tag">Provider-agnostic</span>
<span class="tag">~53K dòng Python</span>
<span class="tag">Windows · macOS · Linux</span>
</div>
</div>
<section class="wrap">
<h2><span class="num">01</span> Tổng quan &amp; nguyên tắc</h2>
<p class="sub">Bốn nguyên tắc định hình toàn bộ kiến trúc.</p>
<div class="grid">
<div class="card"><h4><span class="ic">▤</span>Local-first</h4><p>Cấu hình, lịch sử hội thoại, workspace và nhật ký đều nằm trên máy người dùng. Chỉ lệnh gọi mô hình mới ra ngoài.</p></div>
<div class="card"><h4><span class="ic">⛨</span>Bảo mật nhiều lớp</h4><p>Mọi tool có tác động (chạy lệnh, ghi tệp, tải URL) đi qua chuỗi kiểm soát fail-closed; xem tài liệu <a href="security.html">Bảo mật</a>.</p></div>
<div class="card"><h4><span class="ic">⧉</span>Đa workspace</h4><p>Nhiều project chạy song song, mỗi project nhiều hội thoại Cowork và nhiều luồng Co4E — không cái nào chặn cái nào.</p></div>
<div class="card"><h4><span class="ic">⇄</span>Provider-agnostic</h4><p>Nhiều nhà cung cấp mô hình (OpenAI-compatible…), tự động định tuyến chọn mô hình phù hợp trong số các model được bật.</p></div>
</div>
</section>
<section class="wrap">
<h2><span class="num">02</span> Ngăn xếp công nghệ</h2>
<p class="sub">Những thư viện/thành phần chủ chốt và vai trò của chúng.</p>
<div class="chips">
<span class="chip"><b>PySide6/Qt6</b> · toàn bộ giao diện, đa luồng QThread</span>
<span class="chip"><b>FastAPI + uvicorn</b> · Routing API (chỉ localhost)</span>
<span class="chip"><b>MCP</b> · kết nối công cụ ngoài (Model Context Protocol)</span>
<span class="chip"><b>MSAL</b> · đăng nhập Microsoft 365</span>
<span class="chip"><b>openpyxl / python-pptx</b> · đọc Office</span>
<span class="chip"><b>opendataloader-pdf</b> · trích xuất PDF</span>
<span class="chip"><b>networkx</b> · đồ thị cấu trúc (GraphRAG)</span>
<span class="chip"><b>keyring</b> · lưu bí mật qua OS</span>
<span class="chip"><b>ctypes / Win32</b> · sandbox AppContainer &amp; Job Object</span>
<span class="chip"><b>Pygments</b> · tô màu mã nguồn</span>
</div>
</section>
<section class="wrap">
<h2><span class="num">03</span> Kiến trúc phân lớp</h2>
<p class="sub">Một yêu cầu đi từ giao diện xuống lớp thực thi rồi ra ngoài — mỗi lớp có trách nhiệm rõ ràng.</p>
<div class="stack">
<div class="layer">
<div class="lh"><span class="tier">UI</span><h4>Lớp giao diện — PySide6</h4><small>người dùng thao tác</small></div>
<div class="chips">
<span class="chip">MainWindow</span><span class="chip">WorkspaceTab / WorkspacePane</span>
<span class="chip">CoworkTab (chat)</span><span class="chip">Co4ETab (flow canvas)</span>
<span class="chip">FolderTab</span><span class="chip">ScheduleTaskTab</span>
<span class="chip">MonitoringTab → Security</span><span class="chip">SettingsDialog</span>
</div>
</div>
<div class="layer">
<div class="lh"><span class="tier">Agent</span><h4>Lớp agent / lõi thực thi</h4><small>điều phối lượt chạy</small></div>
<div class="chips">
<span class="chip"><b>chat_agent</b> · run_cowork</span>
<span class="chip"><b>code_agent</b> · run_code</span>
<span class="chip"><b>co4e_runner</b> · run_workflow</span>
<span class="chip"><b>task_executors</b> · tác vụ theo lịch</span>
<span class="chip"><b>model_routing</b> · assess &amp; chọn model</span>
<span class="chip"><b>agent_security</b> · guardrail</span>
</div>
</div>
<div class="layer">
<div class="lh"><span class="tier">Tool</span><h4>Lớp công cụ &amp; sandbox</h4><small>ranh giới tin cậy</small></div>
<div class="chips">
<span class="chip"><b>ToolContext</b> · confine đường dẫn + scope</span>
<span class="chip">execute_tool</span>
<span class="chip">read/write/edit/list_dir</span>
<span class="chip">run_command · install_package</span>
<span class="chip">fetch_url · jira</span>
<span class="chip"><b>SandboxManager</b> + backends</span>
</div>
</div>
<div class="layer">
<div class="lh"><span class="tier">Provider</span><h4>Lớp nhà cung cấp mô hình</h4><small>gọi ra mạng an toàn</small></div>
<div class="chips">
<span class="chip">providers/* (OpenAI-compatible…)</span>
<span class="chip"><b>tls_trust</b> · phục hồi TLS gateway</span>
<span class="chip">usage_tracker · đo token/chi phí</span>
</div>
</div>
<div class="layer">
<div class="lh"><span class="tier">Ngoài</span><h4>Dịch vụ bên ngoài</h4><small>không tin cậy mặc định</small></div>
<div class="chips">
<span class="chip">LLM APIs</span><span class="chip">MCP servers</span>
<span class="chip">Microsoft 365</span><span class="chip">Jira</span><span class="chip">Web (fetch_url)</span>
</div>
</div>
</div>
</section>
<section class="wrap">
<h2><span class="num">04</span> Các subsystem chính</h2>
<p class="sub">Mỗi khối là một tính năng lớn người dùng thấy được, ánh xạ tới module tương ứng.</p>
<div class="grid">
<div class="card"><h4><span class="ic">▦</span>Workspaces &amp; Projects</h4><p>Nhiều project song song, mỗi cái một pane riêng với sandbox bật/tắt để tiết kiệm tài nguyên.</p><div class="files"><code>workspace_tab.py</code><code>workspace_pane.py</code></div></div>
<div class="card"><h4><span class="ic">💬</span>Cowork · đa hội thoại</h4><p>Nhiều hội thoại trong một project; lượt chạy nền giữ đúng hội thoại gốc kể cả khi bạn chuyển tab.</p><div class="files"><code>chat_panel.py</code><code>cowork_tab.py</code></div></div>
<div class="card"><h4><span class="ic">◈</span>Co4E flows</h4><p>Canvas nhiều bước, agent tùy biến, chế độ auto/plan/manual, chạy song song &amp; theo dõi ở Flow Status.</p><div class="files"><code>co4e_tab.py</code><code>co4e_runner.py</code></div></div>
<div class="card"><h4><span class="ic">⇉</span>Model routing</h4><p>Tự đánh giá &amp; chọn mô hình tốt nhất trong số model được bật theo policy (chất lượng/chi phí/độ trễ).</p><div class="files"><code>core/routing/*</code></div></div>
<div class="card"><h4><span class="ic">⛨</span>Sandbox</h4><p>Chọn backend theo mức rủi ro: best-effort → AppContainer → Windows Sandbox VM.</p><div class="files"><code>sandbox_manager.py</code><code>appcontainer_sandbox.py</code></div></div>
<div class="card"><h4><span class="ic">⏱</span>Scheduler</h4><p>Tác vụ theo lịch (Cowork/Code/Flow), phụ thuộc chuỗi, opt-in chạy lệnh.</p><div class="files"><code>task_scheduler.py</code><code>task_executors.py</code></div></div>
<div class="card"><h4><span class="ic">📊</span>Monitoring</h4><p>Tổng quan chi phí, nhật ký sự kiện/bảo mật, quản trị Tool/Agent, trang Security.</p><div class="files"><code>monitoring_tab.py</code></div></div>
<div class="card"><h4><span class="ic">🗄</span>Lưu trữ</h4><p>Cấu hình + lịch sử theo project + workspace + audit log, tất cả trên máy.</p><div class="files"><code>config.py</code><code>core/history.py</code></div></div>
</div>
</section>
<section class="wrap">
<h2><span class="num">05</span> Mô hình đồng thời</h2>
<p class="sub">Vì sao nhiều lượt chạy song song không giẫm chân nhau.</p>
<h3>Cô lập theo lượt (per-turn)</h3>
<p>Mỗi lượt chat chạy trong một <code>AgentWorker</code> (QThread) riêng. Tại thời điểm bắt đầu, lượt chụp lại bối cảnh <span class="k">home_*</span> (id hội thoại, thư mục làm việc, project) — nên dù người dùng chuyển sang hội thoại khác, lượt nền vẫn ghi kết quả về <strong>đúng hội thoại gốc</strong> và quét đúng thư mục của nó.</p>
<h3>Quản lý luồng Co4E dùng chung</h3>
<p>Một <code>Co4ERunManager</code> duy nhất phục vụ mọi pane, mỗi run gắn <span class="k">project_id</span> để lọc. Khi dừng một worker bị treo, nó được "park" giữ tham chiếu (không GC luồng đang chạy → tránh crash <em>QThread destroyed while running</em>).</p>
<div class="note"><b>Cách ly dừng (Stop):</b> nút Stop chỉ tác động lên các worker của <em>chính</em> hội thoại đó và xóa hàng đợi của riêng nó — dừng ở hội thoại này không ảnh hưởng hội thoại khác.</div>
</section>
<section class="wrap">
<h2><span class="num">06</span> Luồng dữ liệu một lượt chat</h2>
<p class="sub">Từ tin nhắn người dùng đến kết quả — mỗi bước là một điểm kiểm soát.</p>
<ol class="flow">
<li><div><b>Tin nhắn + đính kèm</b><small>Người dùng gửi; tệp/thư mục workspace được nạp qua <code>_augment</code>.</small></div></li>
<li><div><b>Bọc nội dung không tin cậy</b><small>Nội dung tệp/web/tool được rào trong khối <span class="k">UNTRUSTED DATA</span> — model coi là dữ liệu, không phải mệnh lệnh.</small></div></li>
<li><div><b>Định tuyến mô hình</b><small>Auto Routing có thể chọn mô hình phù hợp trong số model được bật.</small></div></li>
<li><div><b>Gọi provider</b><small><code>provider.chat()</code> qua <code>tls_trust</code>; usage_tracker ghi token/chi phí theo hội thoại gốc.</small></div></li>
<li><div><b>Model gọi tool</b><small>Mỗi tool qua: kiểm scope ở executor → human-gate (nếu bật) → classifier → sandbox.</small></div></li>
<li><div><b>Kết quả &amp; lưu</b><small>Văn bản/diff hiện realtime; hội thoại lưu vào <code>.cowork_history</code> của project.</small></div></li>
</ol>
</section>
<section class="wrap">
<h2><span class="num">07</span> Lưu trữ trên máy</h2>
<p class="sub">Dữ liệu nằm ở đâu.</p>
<div class="chips">
<span class="chip"><b>~/.cowork_local/config.json</b> · cấu hình (perm 0o600)</span>
<span class="chip"><b>&lt;project&gt;/.cowork_history</b> · hội thoại theo project</span>
<span class="chip"><b>workspaces/</b> · thư mục làm việc mỗi project</span>
<span class="chip"><b>audit log</b> · mọi tool-call &amp; quyết định quyền (lưu hash lệnh)</span>
<span class="chip"><b>trusted_certs/</b> · cert gateway đã pin</span>
<span class="chip"><b>appcontainer_grants.json</b> · cache cấp quyền sandbox</span>
</div>
<div class="note">Vị trí lịch sử có thể trỏ vào thư mục đồng bộ OneDrive — tiện chia sẻ, nhưng lưu ý dữ liệu tệp đã nạp sẽ được sao lên cloud dạng plaintext. Xem khuyến nghị ở tài liệu <a href="security.html">Bảo mật</a>.</div>
</section>
</main>
<footer><div class="wrap">
<span>Cowork Local — tài liệu nội bộ · Cấu trúc hệ thống</span>
<span><a href="security.html">Bảo mật →</a> &nbsp; <a href="usage.html">Cách dùng →</a></span>
</div></footer>
<script>
(function(){
var root=document.documentElement, key="cowork_docs_theme";
var saved=null; try{saved=localStorage.getItem(key)}catch(e){}
if(saved==="dark"||saved==="light") root.setAttribute("data-theme",saved);
else root.removeAttribute("data-theme");
document.getElementById("themeBtn").addEventListener("click",function(){
var cur=root.getAttribute("data-theme");
if(!cur){ // currently following OS → flip to opposite of OS
cur=window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";
}
var next=cur==="dark"?"light":"dark";
root.setAttribute("data-theme",next);
try{localStorage.setItem(key,next)}catch(e){}
});
})();
</script>
</body>
</html>
@@ -0,0 +1,156 @@
# ADR-001: Kiến Trúc 4 Tầng (Layered / Clean Architecture)
* **Status**: Accepted
* **Date**: 2026-08-21
* **EPIC / Task**: R01-T01
* **Owner**: 🔵 Team Duy (Tech Lead)
* **Áp dụng cho**: toàn bộ mã nguồn mới của `cowork_local` (3 team)
---
## 1. Context (Bối cảnh)
`cowork_local` hiện là một ứng dụng PySide6 desktop local-first ~55.000 dòng Python,
được phát triển nhanh theo hướng feature-first. Hệ quả đo được tại thời điểm viết ADR:
| Vấn đề | Bằng chứng cụ thể trong repo |
| :--- | :--- |
| **God widget** | `ui/co4e_tab.py` 2.089 dòng, `ui/chat_panel.py` 1.795 dòng, `ui/folder_tab.py` 1.590 dòng |
| **Business logic nằm trong widget** | Vòng đời turn chat, quyết định routing, ghép prompt đều nằm trong `ui/chat_panel.py` |
| **Logic trùng lặp 3 nơi** | `ui/chat_panel.py::_apply_routing`, `ui/co4e_tab.py::_apply_co4e_routing`, `ui/folder_tab.py::_ai_apply_routing` là ba bản sao gần như y hệt của cùng một thuật toán |
| **Không test được nếu không có Qt** | Muốn test một quyết định routing phải dựng widget → không chạy được headless, không chạy được nhanh |
| **Side-effect ẩn trong tầng hạ tầng** | Provider tự gọi `core.usage_tracker.record()` ngay trong vòng lặp stream (`providers/openai_compat.py::_record_usage`) |
Ba team (Duy / Nam / Hoa) sẽ sửa song song trên cùng codebase trong 10 ngày. Nếu
không có một ranh giới phụ thuộc được **kiểm chứng tự động**, các thay đổi song song
sẽ hội tụ về đúng cấu trúc rối như cũ.
## 2. Decision (Quyết định)
Mã nguồn mới được tổ chức thành **4 tầng**, với **chiều phụ thuộc một chiều** như sau:
```text
┌─────────────────────────────────────────────────────────────┐
│ presentation/ PySide6 widgets, Qt signals/slots │
│ (chat, co4e, workspace…) Chỉ dựng UI và phát/nhận signal │
└───────────────────────────┬─────────────────────────────────┘
│ gọi xuống (được phép)
┌───────────────────────────▼─────────────────────────────────┐
│ application/ Pure Python orchestration │
│ (conversations, Điều phối use-case, không biết Qt │
│ model_routing…) và không biết HTTP/đĩa cụ thể │
└───────────────────────────┬─────────────────────────────────┘
│ gọi xuống (được phép)
┌───────────────────────────▼─────────────────────────────────┐
│ domain/ Pure Python entities & events │
│ (agents, models…) Frozen dataclass, enum, quy tắc │
│ nghiệp vụ thuần. KHÔNG import gì │
│ từ 3 tầng còn lại. │
└───────────────────────────▲─────────────────────────────────┘
│ implement interface của domain
┌───────────────────────────┴─────────────────────────────────┐
│ infrastructure/ Adapters: network, keyring, đĩa, │
│ (providers, telemetry…) process, Qt-free I/O │
└─────────────────────────────────────────────────────────────┘
```
### 2.1 Quy tắc bất biến (Invariants)
| # | Quy tắc | Được kiểm bởi |
| :--- | :--- | :--- |
| **I1** | `domain/` và `application/` là **100% pure Python** — cấm import `PySide6`, `PyQt5`, `PyQt6`, `shiboken6` | `scripts/check_imports.py` (R01-T03) |
| **I2** | `domain/` **không import** `application/`, `infrastructure/`, `presentation/`, `ui/` | `scripts/check_imports.py` |
| **I3** | `application/` **không import** `presentation/` hay `ui/` | `scripts/check_imports.py` |
| **I4** | Không file production nào vượt **400 dòng** | `scripts/check_loc.py` (R10-T02) |
| **I5** | `presentation/` **không** gọi thẳng provider/HTTP/đĩa — phải đi qua một application service | Code review + I1–I3 |
| **I6** | Mọi input của một use-case được đóng gói thành **snapshot bất biến** (`frozen dataclass`) trước khi rời UI thread | Code review + unit test |
### 2.2 Chiều phụ thuộc được phép
| Từ tầng | Được import | Bị cấm |
| :--- | :--- | :--- |
| `presentation/` | `application/`, `domain/`, PySide6 | — (nên tránh gọi thẳng `infrastructure/`) |
| `application/` | `domain/`, interface do `domain/` định nghĩa | `presentation/`, `ui/`, PySide6 |
| `domain/` | chỉ stdlib | tất cả các tầng khác, PySide6 |
| `infrastructure/` | `domain/`, thư viện ngoài (requests, keyring…) | `presentation/`, `ui/`, PySide6 |
### 2.3 Cách tầng dưới "nói chuyện ngược" lên UI
`application/` **không được** giữ tham chiếu tới widget. Việc trao đổi ngược chiều
đi qua **callback thuần Python nhận một `AgentEvent` có kiểu**
(`domain/agents/agent_event.py`, R04-T02):
```python
# application layer — pure Python, không biết Qt tồn tại
service.run_turn(request, on_event=my_callback)
# presentation layer — chuyển event sang Qt signal ở ranh giới duy nhất này
def my_callback(event: AgentEvent) -> None:
self.agent_event.emit(event) # Qt signal → cập nhật UI trên main thread
```
Đây là **seam** duy nhất giữa hai thế giới: dưới seam là Python thuần test được
offline, trên seam là Qt. Mọi cập nhật UI phải xảy ra qua Qt signal/slot, không
bao giờ gọi trực tiếp từ worker thread.
## 3. Vị trí sở hữu theo team
| Tầng / thư mục | Team | EPIC |
| :--- | :--- | :--- |
| `presentation/chat/`, `application/conversations/`, `application/model_routing/`, `domain/agents/`, `domain/models/`, `infrastructure/providers/`, `infrastructure/telemetry/`, `tests/`, `scripts/` | 🔵 Duy | R01, R03, R04, R08, R10 |
| `presentation/co4e/`, `monitoring/`, `settings/`, `shell/`, `application/workflows/`, `infrastructure/config/`, `secrets/`, `sandbox/` | 🟣 Nam | R02, R08, R09 |
| `presentation/workspace/`, `folder/`, `scheduling/`, `application/workspaces/`, `scheduling/`, `domain/tools/`, `domain/tasks/`, `infrastructure/filesystem/`, `mcp/`, `persistence/` | 🟢 Hoa | R05, R06, R07, R08 |
## 4. Chiến lược di trú (Strangler Fig, không big-bang)
Code cũ trong `core/`, `ui/`, `providers/` **không bị xoá ngay**. Ta bọc dần:
1. **Tạo seam mới** ở tầng đúng (ví dụ `RoutingApplicationService`).
2. **Chuyển call site** cũ sang gọi seam mới (`ui/*.py` chỉ còn vài dòng adapter).
3. **Giữ module cũ làm implementation detail** phía sau seam (ví dụ
`application/model_routing/` vẫn gọi xuống `core/routing/` để dùng lại
scorer/selector đã có test).
4. Chỉ khi mọi call site đã đi qua seam mới → cân nhắc gỡ code cũ.
Nhờ vậy `pytest` luôn xanh giữa các bước, và một team có thể merge mà không chờ
team khác refactor xong.
## 5. Consequences (Hệ quả)
### Tích cực
* Test một quyết định routing / một vòng đời turn chat **không cần Qt, không cần mạng** → suite unit chạy < 1 giây.
* Ba bản sao logic routing hội tụ về một nơi duy nhất → sửa một lần, cả 3 màn hình cùng đúng.
* Người mới có thể thêm một provider mà chỉ chạm `infrastructure/providers/` + `domain/models/`.
* Vi phạm kiến trúc bị chặn ở CI thay vì phát hiện lúc review.
### Tiêu cực / chi phí phải chấp nhận
* Nhiều file nhỏ hơn thay vì vài file lớn → tăng số lần "nhảy file" khi đọc code.
* Tồn tại **hai đường** trong giai đoạn di trú (code cũ + seam mới) cho tới khi call site cuối cùng chuyển xong.
* Phải viết DTO/snapshot rõ ràng thay vì truyền thẳng `self` của widget — tốn thêm code, đổi lại được thread-safety.
## 6. Alternatives considered (Phương án đã cân nhắc)
| Phương án | Lý do loại |
| :--- | :--- |
| **Giữ nguyên, chỉ tách file cho ngắn** | Giải quyết được I4 (LOC) nhưng không giải quyết được nguyên nhân gốc: logic vẫn dính Qt nên vẫn không test được offline. |
| **MVVM/MVP thuần Qt** | Vẫn buộc business logic phụ thuộc vòng đời Qt object; không chạy được trong scheduler headless và trong task nền. |
| **Hexagonal đầy đủ (port/adapter cho mọi thứ)** | Đúng về lý thuyết nhưng quá tốn cho 10 ngày và cho một app desktop 1 process; 4 tầng là điểm cân bằng. |
| **Big-bang rewrite** | Rủi ro hồi quy quá cao khi 3 team sửa song song và không có bộ test bảo vệ đầy đủ. |
## 7. Enforcement (Thực thi)
```bash
python scripts/check_imports.py # I1, I2, I3 — quét AST
python scripts/check_loc.py # I4 — giới hạn 400 dòng
python scripts/run_quality_gate.py # chạy toàn bộ CASAN Gate + pytest
```
CASAN Verification Gate phải PASS trước khi merge bất kỳ PR nào vào `main`.
## 8. Tài liệu liên quan
* `docs/refactor/Feature_Architecture_Proposal.md` — thiết kế tổng thể 10 EPIC
* `docs/refactor/Refactoring_Checklist.md` — bảng tiến độ theo task
* `docs/architecture/dormant-code.md` — danh mục code không còn hoạt động (R01-T05)
+85
View File
@@ -0,0 +1,85 @@
# Dormant / Dead Code Inventory (R01-T05)
* **Task**: R01-T05 — Phân loại và cô lập mã nguồn cũ
* **Owner**: 🔵 Team Duy
* **Ngày quét**: 2026-08-21
* **Phạm vi quét**: toàn bộ `*.py` production (loại trừ `tests/`, `assets/`, `docs/`, `.git/`)
---
## 1. Mục đích
Trước khi 3 team refactor song song, cần biết **file nào thật sự đang chạy**. Refactor
một module đã chết là lãng phí; xoá nhầm một module chỉ được gọi động là gây sự cố
runtime. Tài liệu này phân loại từng ứng viên, kèm **bằng chứng** và **hành động đề xuất**.
## 2. Phương pháp
Quét AST toàn repo, dựng đồ thị import, tìm module **không có module nào khác import**.
Kết quả thô: **43 module**. Sau đó xác minh thủ công từng ứng viên, vì phân tích tĩnh
không thấy 3 kiểu tham chiếu:
| Kiểu tham chiếu ẩn | Ví dụ thật trong repo |
| :--- | :--- |
| Chạy như subprocess | `state.py:285` gọi `python -m cowork_local.mcp_servers.ms365_server` |
| Entry point của gói | `__main__.py` (chạy bằng `python -m cowork_local`) |
| Script chạy tay | `tools/check_*.py`, `scripts/*.py` |
> ⚠️ **Kết luận quan trọng**: 43 module "không ai import" **KHÔNG** đồng nghĩa 43 module chết.
> Sau xác minh, chỉ còn **6 hạng mục (~1.887 dòng)** là dormant thật.
## 3. Phân loại kết quả
### 🟥 A. DORMANT THẬT — không có đường nào chạy tới (ứng viên xoá)
| Module | LOC | Bằng chứng | Rủi ro khi xoá | Hành động |
| :--- | ---: | :--- | :--- | :--- |
| `ui/accounts_tab.py` | 700 | Chỉ xuất hiện trong comment của `i18n.py:92`; không widget nào khởi tạo `AccountsTab` | Thấp — panel Monitoring → Accounts hiện không có đường vào | Cô lập, chờ xác nhận PO rồi xoá |
| `ui/flow_dialog.py` | 596 | Chỉ được nhắc trong docstring `ui/agent_manager_tab.py:4` và comment `i18n.py:2124` | Trung bình — Flow Manager có thể là tính năng tạm ẩn | **Hỏi PO trước**, chưa xoá |
| `security/` (cả package) | 296 | `prompt_validator`, `action_validator`, `attachment_validator`, `audit_logger`, `command_risk_classifier` — không file nào ngoài package tự import. Chức năng **trùng** `core/agent_security.py` + `core/security_rules.py` (đang chạy thật) | Trung bình — dễ nhầm đây là lớp bảo mật đang hoạt động | ⚠️ Ưu tiên cao: xoá hoặc hợp nhất trong **R09 (Team Nam)** |
| `core/codebase_memory_ui.py` | 123 | Không nơi nào import; `core/codebase_memory.py` (bản không-UI) mới là bản đang dùng | Thấp | Xoá |
| `core/graph_server.py` | 115 | Docstring nói phục vụ build không có QtWebEngine, nhưng **không có call site nào**; `ui/structure_graph_view.py` không gọi | Trung bình — có thể là fallback cho bản .exe chưa nối dây | Xác minh với bản đóng gói PyInstaller trước khi xoá |
| `ui/mcp_servers_dialog.py` | 57 | Không import; MCP settings hiện nằm trong `ui/settings_dialog.py` | Thấp | Xoá |
**Tổng: ~1.887 dòng (≈ 3,4% codebase).**
### 🟨 B. KHÔNG CHẾT — chạy qua đường ẩn (giữ nguyên)
| Module | Vì sao phân tích tĩnh báo nhầm |
| :--- | :--- |
| `__main__.py` | Entry point `python -m cowork_local` |
| `mcp_servers/ms365_server.py` | Chạy như tiến trình con — `state.py:285` |
| `core/routing/__init__.py` | Được import qua đường dẫn con (`from .routing.service import RoutingService`), heuristic theo tên lá không thấy |
| `tools/check_*.py` (34 file, 6.608 dòng) | Bộ smoke-test UI chạy tay: `python tools/check_nav.py`. Là **dev tooling**, không phải code chết |
| `scripts/bootstrap_gitea_repo.py`, `scripts/check_imports.py` | Script CLI chạy tay / chạy trong CI |
### 🟩 C. CODE SỐNG NHƯNG "ĐÓNG BĂNG" — đụng vào phải cẩn thận
| Module | LOC | Ghi chú cho người refactor |
| :--- | ---: | :--- |
| `core/chat_agent.py::run_cowork` | 580 | Đang có **characterization test** (`tests/characterization/test_run_cowork.py`, R01-T04). Mọi thay đổi hành vi phải làm cùng lúc với cập nhật snapshot |
| `providers/base.py` | 401 | Là contract chung của mọi provider; đổi chữ ký = vỡ cả 3 team. Đã có contract test (R03-T01) |
| `core/routing/*` | 2.263 | Đã có 79 test đang xanh. R03 **bọc** chứ không viết lại: `application/model_routing/` gọi xuống đây |
## 4. Quy tắc xử lý (bắt buộc)
1. **Không xoá trong cùng PR với refactor.** Xoá code chết là một commit riêng, để `git revert` được độc lập khi có sự cố.
2. **Cô lập trước, xoá sau.** Đánh dấu module bằng docstring cảnh báo, chạy 1 vòng release; không ai báo lỗi mới xoá.
3. **Hạng mục 🟥 A cần một người xác nhận** (PO hoặc chủ tính năng) trước khi xoá — trừ khi rõ ràng là bản trùng lặp (`codebase_memory_ui`, `mcp_servers_dialog`).
4. **Không refactor code trong nhóm 🟥 A.** Nếu một file trong danh sách này >400 dòng, nó **không** tính vào CASAN Check 2 — vì đường đi đúng là xoá, không phải tách nhỏ.
## 5. Việc cần bàn giao
| Hạng mục | Team nhận | EPIC |
| :--- | :--- | :--- |
| `security/` trùng lặp với `core/agent_security.py` | 🟣 Nam | R09 |
| `ui/accounts_tab.py`, `ui/flow_dialog.py`, `ui/mcp_servers_dialog.py` | 🟣 Nam (sở hữu `presentation/shell/`, `settings/`) | R08 |
| `core/graph_server.py`, `core/codebase_memory_ui.py` | 🟢 Hoa (sở hữu `presentation/graph/`) | R06 |
## 6. Cách chạy lại lần quét này
```bash
python scripts/check_imports.py # ranh giới kiến trúc (R01-T03)
# Bản quét đồ thị import dùng cho tài liệu này sẽ được đóng gói thành
# scripts/find_dormant.py trong R10-T02 (Testing & Governance tooling).
```
+473
View File
@@ -0,0 +1,473 @@
# 📋 COWORK-LOCAL BamBOO — Danh Sách Chức Năng Chi Tiết Theo Navigation Bar
---
## 🔹 1. 📊 DASHBOARD (Bảng Điều Khiển)
### 1.1 Token Usage & Cost — Thống Kê Token & Chi Phí
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 1.1.1 | `_refresh_cards()` | Làm mới các thẻ thống kê (Total, Input, Output, Cache tokens + Cost) |
| 1.1.2 | `_refresh_chart()` | Vẽ biểu đồ spline theo chu kỳ (week/month/year) và metric (cost/tokens) |
| 1.1.3 | `_chart_prev()` / `_chart_next()` | Chuyển đến chu kỳ trước/sau trên biểu đồ |
| 1.1.4 | `_on_gran_changed()` | Thay đổi đơn vị thời gian (week/month/year) |
| 1.1.5 | `_refresh_budget()` | Cập nhật ngân sách (budget card — còn lại / đã dùng / cảnh báo >85%) |
| 1.1.6 | `_apply_budget()` | Lưu giá trị budget mới |
| 1.1.7 | `_refresh_habits()` | Hiển thị thói quen sử dụng (task tốn nhiều token nhất, trung bình/prompt, ngày/giờ bận nhất) |
| 1.1.8 | `_ai_analyze()` | ✨ AI phân tích thói quen dùng token và gợi ý tiết kiệm |
| 1.1.9 | `_apply_saving_strategy()` | Áp dụng chiến lược tiết kiệm AI (tự nén context, nén sớm hơn) |
| 1.1.10 | Currency Picker | Chọn đơn vị tiền tệ hiển thị (USD, VND, JPY, …) |
---
## 🔹 2. 📅 SCHEDULE TASK (Lên Lịch Nhiệm Vụ)
### 2.1 Kanban Board
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 2.1.1 | `_build_kanban()` | Xây dựng board Kanban với 7 cột: Backlog, Scheduled, Running, Waiting Input, Done, Failed, Paused |
| 2.1.2 | `_render_kanban()` | Render các thẻ task vào từng cột |
| 2.1.3 | `_on_task_dropped(task_id, new_status)` | Kéo thả task giữa các cột (thay đổi status) |
| 2.1.4 | `_on_card_double_click()` | Mở Task Editor khi double-click |
| 2.1.5 | `_on_card_right_click()` | Menu ngữ cảnh: Run now, Edit, Duplicate, Pause, Delete, View logs, Create-next-from-output |
| 2.1.6 | `_bulk_delete_menu()` | Xóa hàng loạt (chọn nhiều thẻ → right-click → Delete N selected) |
| 2.1.7 | `_run_now(task_id)` | Chạy task ngay lập tức |
| 2.1.8 | `_duplicate_task(task_id)` | Sao chép task |
| 2.1.9 | `_pause_task(task_id)` | Tạm dừng task |
| 2.1.10 | `_delete_task(task_id)` | Xóa task |
| 2.1.11 | `_view_logs(task_id)` | Xem log của task |
| 2.1.12 | `_search_tasks()` | Tìm kiếm task theo tên |
| 2.1.13 | `_filter_by_type()` | Lọc task theo loại (cowork/co4e/code/…) |
### 2.2 Calendar View
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 2.2.1 | `_build_calendar()` | Xây dựng chế độ xem lịch |
| 2.2.2 | `_shift(direction)` | Chuyển tháng/tuần trước/sau |
| 2.2.3 | `add_task_on_date(date)` | Thêm task vào ngày cụ thể |
| 2.2.4 | `edit_task(task_id)` | Sửa task từ lịch |
### 2.3 Add / AI Create Task
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 2.3.1 | `_open_add_dialog()` | Mở dialog thêm task thủ công |
| 2.3.2 | `_ai_create_task()` | Mở dialog AI tạo task tự động |
| 2.3.3 | `_ai_pick_files()` | Chọn file đính kèm cho AI planner |
| 2.3.4 | `_generate()` | AI tạo kế hoạch tasks từ mô tả |
| 2.3.5 | `_on_planned(result)` | Hiển thị preview các task AI đề xuất |
| 2.3.6 | `_confirm()` | Xác nhận và tạo các task từ AI plan |
### 2.4 AI Import Tasks
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 2.4.1 | `_ai_import()` | AI nhập task từ file/link |
| 2.4.2 | `_ai_pick_import_files()` | Chọn file để import |
| 2.4.3 | `_generate_import()` | AI phân tích file và tạo tasks |
| 2.4.4 | `_on_import_planned()` | Hiển thị preview import |
---
## 🔹 3. 🏠 WORKSPACE (Không Gian Làm Việc)
### 3.1 Projects — Quản Lý Dự Án (Tab 0)
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.1.1 | `_create()` | Tạo dự án mới |
| 3.1.2 | `_delete()` | Xóa dự án (có xác nhận) |
| 3.1.3 | `_save()` | Lưu thông tin dự án (name, description, instructions, folder) |
| 3.1.4 | `_pick_folder()` | Chọn workspace folder cho dự án |
| 3.1.5 | `_open_workspace()` | Mở folder workspace trong file explorer |
| 3.1.6 | `_select_project_row(project_id)` | Chọn dự án trong danh sách |
| 3.1.7 | `_refresh_sandbox_toggle()` | Bật/tắt sandbox cho dự án |
| 3.1.8 | `refresh()` | Làm mới danh sách dự án |
### 3.2 Workspace Pane — Mỗi Dự Án Mở (Tab 1..N)
#### 3.2.1 🤖 COWORK — Chat Với AI Agent
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.1.1 | `new_session()` | Tạo phiên chat mới |
| 3.2.1.2 | `send_message()` | Gửi tin nhắn đến AI agent |
| 3.2.1.3 | `_build_job()` | Xây dựng job cho AgentWorker (gọi `run_cowork`) |
| 3.2.1.4 | `_cleanup_turn(ctx, ok)` | Dọn dẹp sau khi turn kết thúc (promote files, xóa sandbox) |
| 3.2.1.5 | `_promote_turn_outputs()` | Di chuyển file đầu ra từ sandbox lên session output |
| 3.2.1.6 | `_refresh_outputs_from_disk()` | Làm mới danh sách output files |
| 3.2.1.7 | `_pick_output_folder()` | Chọn thư mục output |
| 3.2.1.8 | `_open_skills_manager()` | Mở Skill Manager |
| 3.2.1.9 | `refresh_header()` | Làm mới header (project name, model info) |
| 3.2.1.10 | `refresh_agents()` | Làm mới danh sách agents trong combo |
| 3.2.1.11 | `admin_agent_prompt()` | Lấy prompt từ agent preset đã chọn |
| 3.2.1.12 | `build_provider()` | Xây dựng provider từ cấu hình agent/model |
| 3.2.1.13 | `workspace_dir()` | Trả về workspace directory hiện tại |
| 3.2.1.14 | `_start_watching(dir)` | Giám sát folder output (file watcher) |
| 3.2.1.15 | `_on_file_changed()` | Xử lý khi file output thay đổi |
**ChatPanel (Class cha của CoworkTab):**
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.1.16 | `_submit_message()` | Gửi tin nhắn (kiểm tra queue, parallel limit) |
| 3.2.1.17 | `_on_turn_started()` | Khi turn bắt đầu (show thinking indicator) |
| 3.2.1.18 | `_on_turn_finished()` | Khi turn kết thúc (update UI, queue next) |
| 3.2.1.19 | `_on_event(ev)` | Xử lý streaming events (text delta, tool calls, plan) |
| 3.2.1.20 | `_compress_messages()` | Nén tin nhắn cũ để giảm token |
| 3.2.1.21 | `_on_agent_changed()` | Khi thay đổi agent trong combo |
| 3.2.1.22 | `_apply_routing()` | Áp dụng model routing (Auto/Manual/Off) |
| 3.2.1.23 | `_note_agent_switch()` | Ghi chú khi agent thay đổi giữa các turn |
| 3.2.1.24 | `_ensure_conversation()` | Đảm bảo conversation tab tồn tại |
| 3.2.1.25 | `load_conversation()` | Load hội thoại từ disk |
| 3.2.1.26 | `running_session_ids()` | Trả về danh sách session đang chạy |
| 3.2.1.27 | `active_workers()` | Trả về danh sách worker đang hoạt động |
| 3.2.1.28 | `_save_conversation()` | Tự động lưu hội thoại |
**Composer (Composer input box):**
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.1.29 | `send()` | Gửi tin nhắn |
| 3.2.1.30 | `attach_files()` | Đính kèm file |
| 3.2.1.31 | `attach_links()` | Đính kèm link URL |
| 3.2.1.32 | `has_any_queue()` | Kiểm tra queue có tin nhắn chờ |
| 3.2.1.33 | `_parse_directives()` | Phân tích directives inline (`/agent:name`, `/skill:name`) |
| 3.2.1.34 | `_show_autocomplete()` | Hiển thị gợi ý tự động |
#### 3.2.2 ⚡ CO4E — Node-Graph Workflow Studio
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.2.1 | `_build_sidebar()` | Xây dựng sidebar (Workflows / Agents / Skills tabs) |
| 3.2.2.2 | `_build_canvas()` | Xây dựng canvas node-graph |
| 3.2.2.3 | `_build_config_panel()` | Xây dựng config panel bên phải |
| 3.2.2.4 | `_toggle_config()` | Thu/mở config panel |
| 3.2.2.5 | `_build_canvas_overlay()` | Zoom +/− và Fit buttons trên canvas |
**Workflows (Sidebar):**
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.2.6 | `_refresh_flows_list()` | Làm mới danh sách flows |
| 3.2.2.7 | `_create_flow()` | Tạo flow mới |
| 3.2.2.8 | `_delete_flow()` | Xóa flow |
| 3.2.2.9 | `_duplicate_flow()` | Sao chép flow |
| 3.2.2.10 | `_import_flow()` | Import flow từ file |
| 3.2.2.11 | `_export_flow()` | Export flow ra file |
| 3.2.2.12 | `_run_flow()` | Chạy flow (foreground/background) |
| 3.2.2.13 | `_stop_flow()` | Dừng flow đang chạy |
| 3.2.2.14 | `_open_flow()` | Mở flow trên canvas |
**Agents (Sidebar):**
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.2.15 | `_refresh_agents_list()` | Làm mới danh sách agents |
| 3.2.2.16 | `_create_agent()` | Tạo agent mới (dialog) |
| 3.2.2.17 | `_edit_agent()` | Sửa agent |
| 3.2.2.18 | `_delete_agent()` | Xóa agent |
| 3.2.2.19 | `_toggle_agent_enabled()` | Bật/tắt agent |
**Skills (Sidebar):**
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.2.20 | `_refresh_skills_list()` | Làm mới danh sách skills |
**Canvas (Node-Graph):**
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.2.21 | `zoom_in()` / `zoom_out()` | Zoom canvas |
| 3.2.2.22 | `fit_view()` | Auto-fit canvas |
| 3.2.2.23 | `_add_node()` | Thêm node lên canvas |
| 3.2.2.24 | `_delete_node()` | Xóa node |
| 3.2.2.25 | `_connect_nodes()` | Kết nối 2 nodes |
| 3.2.2.26 | `_drag_node()` | Kéo thả node |
| 3.2.2.27 | `_select_node()` | Chọn node (→ config panel) |
| 3.2.2.28 | `_activate_node()` | Double-click node |
**Run Modes:**
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.2.29 | `_set_run_mode("auto")` | Auto mode: agent tự plan rồi execute |
| 3.2.2.30 | `_set_run_mode("plan")` | Plan mode: chỉ tạo kế hoạch |
| 3.2.2.31 | `_set_run_mode("manual")` | Manual mode: từng bước, bấm "Next step" |
| 3.2.2.32 | `_run_step()` | Chạy bước tiếp theo (manual mode) |
| 3.2.2.33 | `_on_step_finished()` | Xử lý khi bước hoàn thành |
| 3.2.2.34 | `_render_plan()` | Render plan checklist |
**Chat/Output (Bottom):**
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.2.35 | `_get_flow_chat(flow_id)` | Lấy ChatView cho flow (tạo mới nếu chưa có) |
| 3.2.2.36 | `_on_chat_event()` | Xử lý event từ chat |
#### 3.2.3 📁 FOLDER — File Explorer
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.3.1 | `set_root(path)` | Đặt thư mục gốc |
| 3.2.3.2 | `_build_tree_view()` | Xây dựng cây thư mục (QFileSystemModel) |
| 3.2.3.3 | `_open_file(path)` | Mở file được chọn |
| 3.2.3.4 | `_view_source()` | Xem source code (syntax highlighting) |
| 3.2.3.5 | `_view_html_preview()` | Preview HTML (WebEngine/rich text) |
| 3.2.3.6 | `_view_office_doc()` | Xem Office doc (docx/pdf/xlsx/…) |
| 3.2.3.7 | `_view_image()` | Hiển thị ảnh inline |
| 3.2.3.8 | `_edit_file()` | Chỉnh sửa file (code editor) |
| 3.2.3.9 | `_save_file()` | Lưu file |
| 3.2.3.10 | `_preview_toggle()` | Chuyển đổi Preview ⇄ Edit |
| 3.2.3.11 | `_create_new_file()` | Tạo file mới |
| 3.2.3.12 | `_create_new_folder()` | Tạo folder mới |
| 3.2.3.13 | `_rename_item()` | Đổi tên file/folder |
| 3.2.3.14 | `_delete_item()` | Xóa file/folder |
| 3.2.3.15 | `_copy_item()` | Sao chép file/folder |
| 3.2.3.16 | `_paste_item()` | Dán file/folder |
| 3.2.3.17 | `refresh_ai_models()` | Làm mới danh sách AI models cho AI Edit |
**AI Edit (Chỉnh Sửa File Bằng AI):**
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.3.18 | `_ai_send()` | Gửi yêu cầu AI edit |
| 3.2.3.19 | `_ai_apply()` | Áp dụng thay đổi AI |
| 3.2.3.20 | `_ai_discard()` | Hủy thay đổi AI |
| 3.2.3.21 | `_reset_ai_conversation()` | Xóa hội thoại AI edit |
| 3.2.3.22 | `_ai_apply_routing()` | Áp dụng routing cho AI edit |
#### 3.2.4 🧠 GRAPH RAG — Knowledge Graph
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.4.1 | `_build_graph()` | Xây dựng knowledge graph từ code/documents |
| 3.2.4.2 | `_render_d3_graph()` | Render graph bằng D3.js (WebEngine) |
| 3.2.4.3 | `_render_native_graph()` | Render graph bằng QGraphicsView (fallback) |
| 3.2.4.4 | `_auto_rotate()` | Tự xoay graph khi idle |
| 3.2.4.5 | `_on_node_click()` | Xử lý click node (mở folder) |
| 3.2.4.6 | `_open_node_path()` | Mở folder chứa node |
| 3.2.4.7 | `_refresh_graph()` | Tự cập nhật graph khi có output mới |
| 3.2.4.8 | `_search_graph()` | Tìm kiếm trong graph |
| 3.2.4.9 | `_filter_by_kind()` | Lọc node theo loại |
| 3.2.4.10 | `_zoom_graph()` | Zoom graph |
**Graph-RAG Q&A:**
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 3.2.4.11 | `_ask_question()` | Hỏi AI về graph |
| 3.2.4.12 | `_on_ask_event()` | Xử lý streaming answer |
| 3.2.4.13 | `_on_ask_done()` | Khi AI trả lời xong |
| 3.2.4.14 | `_candidate_file_paths()` | Lấy danh sách file để extract |
| 3.2.4.15 | `_extract_tmp_dir()` | Tạo thư mục tạm cho extraction |
| 3.2.4.16 | `_clear_extracts()` | Xóa dữ liệu extract tạm |
---
## 🔹 4. 📊 MONITORING (Giám Sát)
### 4.1 Overview — Tổng Quan
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 4.1.1 | `_refresh_overview()` | Làm mới tất cả cards overview |
| 4.1.2 | `_refresh_usage_cards()` | Token Usage & Cost cards (Total, Input, Output, Cache) |
| 4.1.3 | `_refresh_resource_usage()` | Resource usage (CPU, RAM, Disk) |
| 4.1.4 | `_refresh_recent_activity()` | Hoạt động gần đây |
| 4.1.5 | `_refresh_sandbox_details()` | Chi tiết sandbox (PID, uptime, limits) |
| 4.1.6 | `_refresh_permissions()` | Hiển thị permissions hiện tại |
| 4.1.7 | `_refresh_audit_log()` | Audit log gần đây |
| 4.1.8 | `_refresh_budget()` | Budget card (còn lại / đã dùng) |
| 4.1.9 | `_apply_budget()` | Lưu budget mới |
### 4.2 Security Events — Sự Kiện Bảo Mật
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 4.2.1 | `_refresh_security_events()` | Làm mới bảng security events (audit log `kind="security_block"`) |
| 4.2.2 | `_filter_security_events()` | Lọc sự kiện bảo mật |
| 4.2.3 | `_sort_events()` | Sắp xếp bảng events |
### 4.3 MCP Call History — Lịch Sử Gọi MCP
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 4.3.1 | `_refresh_mcp_calls()` | Làm mới bảng MCP calls (audit log `kind="mcp_call"`) |
| 4.3.2 | `_filter_mcp_calls()` | Lọc MCP calls |
### 4.4 Action Logs — Nhật Ký Hành Động
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 4.4.1 | `_refresh_action_logs()` | Làm mới bảng action logs (toàn bộ audit log) |
| 4.4.2 | `_filter_action_logs()` | Lọc action logs |
| 4.4.3 | `_sort_action_logs()` | Sắp xếp action logs |
### 4.5 Agent Status — Trạng Thái Agent
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 4.5.1 | `_refresh_agent_status()` | Làm mới trạng thái các agent (Cowork, Co4E, Schedule, GraphRAG) |
### 4.6 Security Settings — Cài Đặt Bảo Mật
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 4.6.1 | `_toggle_sandbox()` | Bật/tắt sandbox |
| 4.6.2 | `_toggle_network_block()` | Chặn kết nối mạng |
| 4.6.3 | `_set_resource_limits()` | Đặt giới hạn tài nguyên (CPU/RAM/Disk) |
| 4.6.4 | `_toggle_command_confirm()` | Xác nhận trước khi chạy lệnh |
| 4.6.5 | `_manage_permissions()` | Quản lý quyền truy cập |
---
## 🔹 5. ⚙️ SETTINGS (Cài Đặt)
### 5.1 AI Provider — Nhà Cung Cấp AI
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 5.1.1 | `_on_provider_changed()` | Khi thay đổi provider |
| 5.1.2 | `_load_models(provider)` | Load danh sách models của provider |
| 5.1.3 | `_test_connection(provider)` | Kiểm tra kết nối provider |
| 5.1.4 | `_stash_provider_fields()` | Lưu tạm các trường cấu hình provider |
| 5.1.5 | `_apply_provider_fields()` | Áp dụng các trường cấu hình provider |
| 5.1.6 | Model List Widget | Hiển thị danh sách models (enable/disable, chọn default) |
### 5.2 Connectors (MCP) — Kết Nối
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 5.2.1 | `_add_mcp_server()` | Thêm MCP server mới |
| 5.2.2 | `_edit_mcp_server()` | Sửa MCP server |
| 5.2.3 | `_delete_mcp_server()` | Xóa MCP server |
| 5.2.4 | `_test_mcp_connection()` | Kiểm tra kết nối MCP |
| 5.2.5 | MS365 Connector | Kết nối Microsoft 365 (tự động khi đăng nhập) |
| 5.2.6 | CAD/CAE Connectors | Kết nối CAD/CAE tools |
### 5.3 Parameters — Tham Số
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 5.3.1 | `attach_tokens` | Giới hạn token cho attachments |
| 5.3.2 | `attach_files` | Giới hạn số file attachments |
| 5.3.3 | `struct_nodes` | Giới hạn nodes cho GraphRAG |
| 5.3.4 | `struct_edges` | Giới hạn edges cho GraphRAG |
### 5.4 Model Routing — Định Tuyến Model
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 5.4.1 | `routing_mode` | Chế độ routing (Off/Auto/Manual) |
| 5.4.2 | `routing_policy` | Chính sách routing |
| 5.4.3 | `routing_min_gain` | Threshold tối thiểu để chuyển model |
| 5.4.4 | `routing_timeout` | Timeout xác nhận routing |
| 5.4.5 | `routing_interval` | Khoảng thời gian đánh giá lại |
| 5.4.6 | `routing_concurrency` | Số lượng request đồng thời per provider |
| 5.4.7 | `routing_judge` | Model dùng để đánh giá routing |
### 5.5 General — Chung
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 5.5.1 | Language Picker | Chọn ngôn ngữ (EN/VI/JP) |
| 5.5.2 | `tray_chk` | Minimize to tray thay vì đóng |
| 5.5.3 | `notify_chk` | Thông báo khi task hoàn thành |
| 5.5.4 | `_save()` | Lưu tất cả cài đặt |
---
## 🔹 6. 📜 HISTORY SIDEBAR (Thanh Lịch Sử Bên Trái)
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 6.0.1 | `refresh()` | Làm mới danh sách hội thoại |
| 6.0.2 | `set_view_state(session_id, running_ids)` | Đánh dấu hội thoại hiện tại + đang chạy |
| 6.0.3 | `set_project_filter(project_id)` | Lọc theo dự án |
| 6.0.4 | `_open_chat()` | Mở hội thoại khi click |
| 6.0.5 | `_context_menu()` | Menu chuột phải (Pin/Unpin, Rename, Delete) |
| 6.0.6 | `_bulk_delete_menu()` | Menu xóa hàng loạt |
| 6.0.7 | `_confirm_and_delete_selected()` | Xác nhận và xóa các hội thoại đã chọn |
| 6.0.8 | `new_chat(kind)` | Tạo hội thoại mới |
| 6.0.9 | `collapse_requested()` | Thu nhỏ sidebar |
| 6.0.10 | `expand_requested()` | Mở rộng sidebar |
---
## 🔹 7. 🧩 CÁC CHỨC NĂNG TOÀN CẦU (Global)
### 7.1 MainWindow (app.py)
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 7.1.1 | `_build_topbar()` | Xây dựng thanh trên cùng (User name, Settings, Language) |
| 7.1.2 | `_build_nav_rail()` | Xây dựng thanh điều hướng bên trái |
| 7.1.3 | `_toggle_nav()` | Thu/mở nav rail (icon-only ↔ full) |
| 7.1.4 | `_apply_nav_labels()` | Áp dụng labels cho nav items |
| 7.1.5 | `_ensure_page(row)` | Xây dựng page lười (lazy loading) |
| 7.1.6 | `_refresh_history()` | Làm mới history của tất cả panes |
| 7.1.7 | `_on_scheduled_task_done()` | Thông báo khi scheduled task hoàn thành |
| 7.1.8 | `_notify_task()` | Thông báo khi task hoàn thành |
| 7.1.9 | `_on_projects_changed()` | Khi danh sách dự án thay đổi |
| 7.1.10 | `_on_pane_turn_finished()` | Khi turn trong pane hoàn thành |
| 7.1.11 | `_open_settings()` | Mở dialog cài đặt |
| 7.1.12 | `_fit_to_screen()` | Tự động fit cửa sổ theo màn hình |
| 7.1.13 | Toast notifications | Hiển thị thông báo toast |
| 7.1.14 | System Tray | Minimize to tray, tray notifications |
### 7.2 Skills Manager
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 7.2.1 | `_open_skills_manager()` | Mở Skill Manager |
| 7.2.2 | `seed_library_skills()` | Gieo skills mặc định |
| 7.2.3 | `prune_seeded_builtins()` | Dọn dẹp skills built-in |
### 7.3 Welcome Dialog
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 7.3.1 | `maybe_show_welcome()` | Hiển thị dialog chào mừng lần đầu |
### 7.4 i18n (Đa Ngôn Ngữ)
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 7.4.1 | `tr(key)` | Dịch chuỗi theo ngôn ngữ hiện tại |
| 7.4.2 | `set_language(lang)` | Đặt ngôn ngữ |
| 7.4.3 | `get_language()` | Lấy ngôn ngữ hiện tại |
| 7.4.4 | `on_language_changed(callback)` | Đăng ký callback khi ngôn ngữ thay đổi |
### 7.5 Task Scheduler (Nền)
| # | Tên Hàm / Chức Năng | Mô Tả |
|---|---------------------|--------|
| 7.5.1 | `task_finished` signal | Khi scheduled task hoàn thành |
| 7.5.2 | `history_ready` signal | Khi session của task sẵn sàng |
| 7.5.3 | `running_session_ids()` | Lấy danh sách session đang chạy |
---
## 📌 TỔNG KẾT
| Navigation Item | Số Hàm/Chức Năng |
|----------------|:-:|
| 📊 Dashboard | ~10 |
| 📅 Schedule Task | ~25 |
| 🏠 Workspace → Projects | ~8 |
| 🏠 Workspace → Cowork | ~34 |
| 🏠 Workspace → Co4E | ~36 |
| 🏠 Workspace → Folder | ~22 |
| 🏠 Workspace → Graph RAG | ~16 |
| 📊 Monitoring | ~20 |
| ⚙️ Settings | ~25 |
| 📜 History Sidebar | ~10 |
| 🌐 Global Functions | ~15 |
| **TỔNG CỘNG** | **~221** |
> **Lưu ý:** Đây là danh sách các hàm/chức năng ở cấp UI và business logic chính. Các hàm core (providers, MCP, worker, security…) nằm ở tầng dưới và được gọi bởi các hàm UI ở trên.
+540
View File
@@ -0,0 +1,540 @@
<!doctype html>
<html lang="vi">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Tìm hiểu RAG — Hỏi &amp; Đáp</title>
<style>
:root{
--navy:#1B3C87; --blue:#0A4EA3; --acc:#1565C0; --acc2:#4A90D9;
--bg:#fff; --sf:#F7F9FC; --card:#fff; --bd:#E3E8EF; --bds:#CBD5E1;
--tx:#2B3542; --mut:#5A6675; --fnt:#8A94A3;
--ok:#1B7A3D; --okbg:#E8F5EC; --warn:#B26A00; --warnbg:#FDF3E3;
--bad:#C0392B; --badbg:#FCEDEC; --r:10px;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--tx);
font:15px/1.6 "Segoe UI Variable Text","Segoe UI",system-ui,sans-serif}
.bar{background:var(--navy);color:#fff;padding:12px 28px;font-weight:700;font-size:16px;
display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:9}
.bar .sub{font-weight:400;opacity:.85;font-size:13px}
.wrap{max-width:1060px;margin:0 auto;padding:28px 28px 80px}
h1{color:var(--blue);font-size:30px;margin:14px 0 6px;letter-spacing:-.02em}
h2{color:var(--blue);font-size:20px;margin:40px 0 4px;padding-top:18px;
border-top:2px solid var(--bd)}
.lead{color:var(--mut);margin:0 0 8px}
.qa{border:1px solid var(--bd);border-radius:var(--r);margin:14px 0;background:var(--card);
box-shadow:0 1px 2px rgba(16,32,64,.04)}
.q{padding:13px 18px;font-weight:700;color:var(--blue);font-size:15.5px;
display:flex;gap:10px;align-items:flex-start}
.q .n{background:var(--acc);color:#fff;border-radius:5px;min-width:26px;height:22px;
display:inline-flex;align-items:center;justify-content:center;font-size:12px;flex:none}
.a{padding:0 18px 15px 54px;color:var(--tx)}
.a p{margin:0 0 8px}
.a ul{margin:6px 0;padding-left:20px}.a li{margin:3px 0}
b{color:var(--blue)}
code{font:13px "Cascadia Code",Consolas,monospace;background:var(--sf);
border:1px solid var(--bd);border-radius:4px;padding:1px 5px;color:#0F3D6E}
.note{border-left:4px solid var(--acc);background:#EAF2FC;border-radius:6px;
padding:10px 14px;margin:10px 0}
.note.ok{border-left-color:var(--ok);background:var(--okbg)}
.note.warn{border-left-color:var(--warn);background:var(--warnbg)}
.note.bad{border-left-color:var(--bad);background:var(--badbg)}
figure{margin:12px 0;padding:14px;background:var(--sf);border:1px solid var(--bd);
border-radius:var(--r)}
figure svg{display:block;width:100%;height:auto}
figcaption{color:var(--fnt);font-size:12.5px;margin-top:8px;text-align:center}
table{width:100%;border-collapse:collapse;margin:10px 0;font-size:14px}
th,td{text-align:left;padding:7px 11px;border-bottom:1px solid var(--bd);vertical-align:top}
th{color:var(--mut);font-size:12px;text-transform:uppercase;letter-spacing:.05em}
.toc{background:var(--sf);border:1px solid var(--bd);border-radius:var(--r);padding:14px 20px}
.toc ol{margin:6px 0;padding-left:20px;columns:2;column-gap:32px;font-size:14px}
.toc a{color:var(--tx);text-decoration:none}.toc a:hover{color:var(--acc)}
@media(max-width:820px){.toc ol{columns:1}.a{padding-left:18px}}
</style>
</head>
<body>
<div class="bar"><span>Tìm hiểu RAG — Hỏi &amp; Đáp</span>
<span class="sub">Chuẩn bị cho phần Q&amp;A sau buổi trình bày</span></div>
<div class="wrap">
<h1>Những câu hay được hỏi nhất</h1>
<p class="lead">20 câu, xếp từ dễ tới khó. Năm câu cuối là về chính dự án Cowork-Local —
nhóm câu này gần như chắc chắn sẽ có người hỏi.</p>
<div class="toc"><b>Nội dung</b>
<ol>
<li><a href="#q1">RAG là gì, nói gọn trong một câu?</a></li>
<li><a href="#q2">RAG khác fine-tuning thế nào?</a></li>
<li><a href="#q3">RAG có xoá hết bịa đặt không?</a></li>
<li><a href="#q4">Vector là gì mà so sánh được nghĩa?</a></li>
<li><a href="#q5">Chia đoạn bao nhiêu chữ là đúng?</a></li>
<li><a href="#q6">Overlap để làm gì?</a></li>
<li><a href="#q7">top-K nên đặt bao nhiêu?</a></li>
<li><a href="#q8">Chọn mô hình embedding thế nào? Tiếng Việt thì sao?</a></li>
<li><a href="#q9">Bắt buộc phải có Vector DB không?</a></li>
<li><a href="#q10">Chỉ tìm theo vector đã đủ chưa?</a></li>
<li><a href="#q11">Câu hỏi cần nối nhiều tài liệu thì sao?</a></li>
<li><a href="#q12">Context window đã 1 triệu token, còn cần RAG?</a></li>
<li><a href="#q13">Chi phí thực tế bao nhiêu?</a></li>
<li><a href="#q14">RAG làm chậm bao nhiêu?</a></li>
<li><a href="#q15">Tài liệu sửa thì cập nhật thế nào?</a></li>
<li><a href="#q16">Đo chất lượng RAG bằng gì?</a></li>
<li><a href="#q17">Phân quyền tài liệu xử lý ra sao?</a></li>
<li><a href="#q18">Cowork-Local đã có RAG chưa?</a></li>
<li><a href="#q19">GraphRAG của dự án có phải GraphRAG của Microsoft?</a></li>
<li><a href="#q20">Muốn nâng lên RAG đầy đủ cần làm gì?</a></li>
</ol></div>
<h2>Nhóm 1 — Khái niệm</h2>
<div class="qa" id="q1"><div class="q"><span class="n">1</span>
RAG là gì, nói gọn trong một câu?</div>
<div class="a">
<p><b>Tìm tài liệu liên quan trước, rồi đưa cho LLM đọc và trả lời dựa trên đó</b> — thay vì
để LLM trả lời bằng trí nhớ có sẵn.</p>
<p>Ví von: thay vì bắt thí sinh làm bài từ trí nhớ, ta cho <i>thi mở sách</i> — nhưng có
thủ thư lật sẵn đúng trang cần đọc.</p>
</div></div>
<div class="qa" id="q2"><div class="q"><span class="n">2</span>
RAG khác fine-tuning thế nào? Khi nào dùng cái nào?</div>
<div class="a">
<figure>
<svg viewBox="0 0 720 190" role="img" aria-label="So sánh RAG và fine-tuning">
<rect x="8" y="14" width="340" height="162" rx="8" fill="#EAF2FC" stroke="#1565C0"/>
<text x="26" y="40" font-size="15" font-weight="700" fill="#0A4EA3">RAG — đưa thêm tài liệu</text>
<rect x="26" y="56" width="86" height="34" rx="5" fill="#fff" stroke="#4A90D9"/>
<text x="69" y="77" font-size="12" text-anchor="middle" fill="#2B3542">Câu hỏi</text>
<path d="M116 73 h22" stroke="#5A6675" stroke-width="1.6" marker-end="url(#ar)"/>
<rect x="142" y="56" width="94" height="34" rx="5" fill="#fff" stroke="#4A90D9"/>
<text x="189" y="72" font-size="11" text-anchor="middle" fill="#2B3542">Tìm tài liệu</text>
<text x="189" y="84" font-size="10" text-anchor="middle" fill="#5A6675">top-K đoạn</text>
<path d="M240 73 h22" stroke="#5A6675" stroke-width="1.6" marker-end="url(#ar)"/>
<rect x="266" y="56" width="66" height="34" rx="5" fill="#1565C0"/>
<text x="299" y="77" font-size="12" text-anchor="middle" fill="#fff">LLM</text>
<text x="26" y="116" font-size="12" fill="#2B3542">✔ Cập nhật tức thì — chỉ re-index</text>
<text x="26" y="136" font-size="12" fill="#2B3542">✔ Trích được nguồn</text>
<text x="26" y="156" font-size="12" fill="#2B3542">✔ Rẻ, không cần GPU train</text>
<rect x="372" y="14" width="340" height="162" rx="8" fill="#FDF3E3" stroke="#B26A00"/>
<text x="390" y="40" font-size="15" font-weight="700" fill="#8A5000">Fine-tune — dạy lại mô hình</text>
<rect x="390" y="56" width="96" height="34" rx="5" fill="#fff" stroke="#D9A24A"/>
<text x="438" y="72" font-size="11" text-anchor="middle" fill="#2B3542">Dữ liệu mẫu</text>
<text x="438" y="84" font-size="10" text-anchor="middle" fill="#5A6675">hàng nghìn cặp</text>
<path d="M490 73 h22" stroke="#5A6675" stroke-width="1.6" marker-end="url(#ar)"/>
<rect x="516" y="56" width="80" height="34" rx="5" fill="#fff" stroke="#D9A24A"/>
<text x="556" y="77" font-size="12" text-anchor="middle" fill="#2B3542">Huấn luyện</text>
<path d="M600 73 h22" stroke="#5A6675" stroke-width="1.6" marker-end="url(#ar)"/>
<rect x="626" y="56" width="70" height="34" rx="5" fill="#B26A00"/>
<text x="661" y="77" font-size="12" text-anchor="middle" fill="#fff">Model mới</text>
<text x="390" y="116" font-size="12" fill="#2B3542">✔ Dạy được <i>văn phong</i>, định dạng</text>
<text x="390" y="136" font-size="12" fill="#2B3542">✔ Dạy được kỹ năng chuyên ngành</text>
<text x="390" y="156" font-size="12" fill="#2B3542">✘ Kiến thức mới → phải train lại</text>
<defs><marker id="ar" markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto">
<path d="M0 0 L7 3.5 L0 7 z" fill="#5A6675"/></marker></defs>
</svg>
<figcaption>RAG thêm <i>kiến thức</i>. Fine-tune thay đổi <i>hành vi</i>.</figcaption>
</figure>
<p><b>Quy tắc chọn:</b> câu trả lời phụ thuộc <i>nội dung tài liệu</i> → RAG.
Phụ thuộc <i>cách nói / định dạng / kỹ năng</i> → fine-tune. Cần cả hai thì làm cả hai.</p>
<div class="note">Đa số bài toán doanh nghiệp là loại thứ nhất, nên RAG hầu như luôn là
bước làm trước.</div>
</div></div>
<div class="qa" id="q3"><div class="q"><span class="n">3</span>
RAG có xoá hết bịa đặt (hallucination) không?</div>
<div class="a">
<p><b>Không. Chỉ giảm mạnh.</b> Đây là câu dễ bị hỏi vặn nhất, nên trả lời thẳng.</p>
<p>RAG vẫn sai được ở bốn chỗ:</p>
<ul>
<li><b>Tra sai đoạn</b> — lấy nhầm tài liệu, LLM trả lời trung thực trên tài liệu sai.</li>
<li><b>Không có trong kho</b> — LLM vẫn cố trả lời thay vì nói "không tìm thấy".</li>
<li><b>Đọc đúng nhưng suy diễn thêm</b> — thêm chi tiết không có trong đoạn trích.</li>
<li><b>Tài liệu gốc đã sai</b> — RAG không kiểm chứng nội dung.</li>
</ul>
<div class="note warn">Cách khắc phục thực dụng: bắt LLM <b>trích dẫn đoạn nguồn</b> cho từng ý,
và cho phép trả lời <b>"không tìm thấy trong tài liệu"</b>. Slide "Ưu điểm" nên nói
<i>giảm</i> hallucination, không nói <i>hết</i>.</div>
</div></div>
<div class="qa" id="q4"><div class="q"><span class="n">4</span>
Vector là gì mà so sánh được "nghĩa giống nhau"?</div>
<div class="a">
<figure>
<svg viewBox="0 0 720 210" role="img" aria-label="Không gian vector, các câu gần nghĩa nằm gần nhau">
<rect x="40" y="14" width="640" height="164" rx="8" fill="#fff" stroke="var(--bds)"/>
<line x1="70" y1="160" x2="660" y2="160" stroke="#CBD5E1"/>
<line x1="70" y1="160" x2="70" y2="30" stroke="#CBD5E1"/>
<circle cx="180" cy="70" r="6" fill="#1565C0"/><text x="192" y="74" font-size="12">"Xe hơi"</text>
<circle cx="214" cy="88" r="6" fill="#1565C0"/><text x="226" y="92" font-size="12">"Ô tô"</text>
<circle cx="196" cy="52" r="6" fill="#1565C0"/><text x="208" y="56" font-size="12">"Xe bốn bánh"</text>
<ellipse cx="200" cy="70" rx="86" ry="46" fill="none" stroke="#1565C0"
stroke-dasharray="4 3" opacity=".6"/>
<circle cx="520" cy="120" r="6" fill="#B26A00"/><text x="532" y="124" font-size="12">"Nấu phở"</text>
<circle cx="556" cy="98" r="6" fill="#B26A00"/><text x="568" y="102" font-size="12">"Công thức bún"</text>
<ellipse cx="540" cy="110" rx="70" ry="38" fill="none" stroke="#B26A00"
stroke-dasharray="4 3" opacity=".6"/>
<circle cx="300" cy="118" r="7" fill="#C0392B"/>
<text x="252" y="140" font-size="12" fill="#C0392B">câu hỏi của user</text>
<line x1="300" y1="118" x2="214" y2="88" stroke="#C0392B" stroke-width="1.4"/>
<text x="236" y="112" font-size="10.5" fill="#C0392B">gần → lấy</text>
<line x1="300" y1="118" x2="520" y2="120" stroke="#CBD5E1" stroke-width="1.2"
stroke-dasharray="3 3"/>
<text x="386" y="134" font-size="10.5" fill="#8A94A3">xa → bỏ qua</text>
</svg>
<figcaption>Mỗi đoạn chữ thành một điểm trong không gian nhiều chiều.
Gần nhau = gần nghĩa.</figcaption>
</figure>
<p>Mô hình embedding biến một đoạn chữ thành dãy số (768 – 4096 chiều). Nó được huấn luyện
sao cho <b>hai đoạn cùng nghĩa cho ra hai điểm gần nhau</b>, kể cả khi không trùng một chữ nào.</p>
<p>Máy đo "gần" bằng <b>cosine similarity</b> — góc giữa hai vector. Nhờ vậy hỏi "xe hơi"
vẫn tìm ra tài liệu viết "ô tô".</p>
<div class="note">Đây chính là điểm RAG hơn tìm kiếm từ khoá: từ khoá cần <i>trùng chữ</i>,
vector chỉ cần <i>trùng nghĩa</i>.</div>
</div></div>
<h2>Nhóm 2 — Tham số kỹ thuật</h2>
<div class="qa" id="q5"><div class="q"><span class="n">5</span>
Chia đoạn bao nhiêu chữ là đúng?</div>
<div class="a">
<p><b>Không có con số đúng chung</b> — phụ thuộc loại tài liệu. Nhưng có nguyên tắc:</p>
<table>
<tr><th>Loại tài liệu</th><th>Cỡ đoạn gợi ý</th><th>Vì sao</th></tr>
<tr><td>FAQ, hỏi đáp ngắn</td><td>100 – 300 chữ</td><td>Mỗi mục vốn đã độc lập</td></tr>
<tr><td>Chính sách, quy trình</td><td>300 – 600 chữ</td><td>Giữ trọn một điều khoản</td></tr>
<tr><td>Sách, báo cáo dài</td><td>500 – 1000 chữ</td><td>Cần đủ ngữ cảnh xung quanh</td></tr>
<tr><td>Mã nguồn</td><td>theo hàm / lớp</td><td>Cắt giữa hàm là hỏng nghĩa</td></tr>
</table>
<div class="note warn"><b>Đoạn quá nhỏ</b> → mất ngữ cảnh, tra ra mảnh vụn vô nghĩa.
<b>Đoạn quá lớn</b> → một đoạn chứa nhiều chủ đề, vector bị "trung bình hoá" nên tra kém chính xác,
lại tốn token.</div>
<p>Thực tế nên <b>cắt theo cấu trúc trước</b> (theo mục, theo điều, theo hàm) rồi mới giới hạn
độ dài — cắt cứng theo số chữ là phương án cuối.</p>
</div></div>
<div class="qa" id="q6"><div class="q"><span class="n">6</span>
Overlap 10–20% để làm gì?</div>
<div class="a">
<figure>
<svg viewBox="0 0 720 150" role="img" aria-label="Chia đoạn có phần chồng lấn">
<text x="20" y="26" font-size="12.5" font-weight="700" fill="#C0392B">Không overlap — câu bị cắt đôi</text>
<rect x="20" y="36" width="200" height="30" rx="4" fill="#EAF2FC" stroke="#4A90D9"/>
<rect x="222" y="36" width="200" height="30" rx="4" fill="#EAF2FC" stroke="#4A90D9"/>
<rect x="424" y="36" width="200" height="30" rx="4" fill="#EAF2FC" stroke="#4A90D9"/>
<text x="120" y="55" font-size="11" text-anchor="middle">đoạn 1</text>
<text x="322" y="55" font-size="11" text-anchor="middle">đoạn 2</text>
<text x="524" y="55" font-size="11" text-anchor="middle">đoạn 3</text>
<line x1="221" y1="30" x2="221" y2="72" stroke="#C0392B" stroke-width="2"/>
<text x="228" y="82" font-size="10.5" fill="#C0392B">"Mức phụ cấp là | 2 triệu/tháng" — mất vế sau</text>
<text x="20" y="110" font-size="12.5" font-weight="700" fill="#1B7A3D">Có overlap — câu nào cũng trọn ở ít nhất 1 đoạn</text>
<rect x="20" y="118" width="210" height="26" rx="4" fill="#E8F5EC" stroke="#1B7A3D"/>
<rect x="196" y="118" width="210" height="26" rx="4" fill="#E8F5EC" stroke="#1B7A3D"
opacity=".75"/>
<rect x="372" y="118" width="210" height="26" rx="4" fill="#E8F5EC" stroke="#1B7A3D"
opacity=".55"/>
<rect x="196" y="118" width="34" height="26" fill="#1B7A3D" opacity=".2"/>
<rect x="372" y="118" width="34" height="26" fill="#1B7A3D" opacity=".2"/>
<text x="600" y="136" font-size="10.5" fill="#1B7A3D">phần tô đậm = chồng lấn</text>
</svg>
<figcaption>Overlap là bảo hiểm cho những câu nằm vắt ngang ranh giới đoạn.</figcaption>
</figure>
<p>Cắt cứng theo số chữ sẽ có lúc cắt <b>giữa một câu hoặc giữa một ý</b>. Đoạn nào cũng
lặp lại một phần đoạn trước thì thông tin ở ranh giới luôn còn nguyên vẹn ở ít nhất một đoạn.</p>
<p>Giá phải trả: kho phình thêm đúng bằng tỉ lệ overlap. 20% overlap → nhiều hơn ~20% vector.</p>
</div></div>
<div class="qa" id="q7"><div class="q"><span class="n">7</span>
top-K nên đặt bao nhiêu?</div>
<div class="a">
<p>Thường <b>3 – 10</b>. Cách chọn:</p>
<ul>
<li><b>K nhỏ (3–5)</b> — câu hỏi tra cứu một dữ kiện. Ít nhiễu, rẻ, nhanh.</li>
<li><b>K lớn (8–15)</b> — câu hỏi tổng hợp, cần gom nhiều nguồn.</li>
</ul>
<div class="note warn">K càng lớn <b>không</b> đồng nghĩa càng chính xác. Đoạn thứ 15 thường
đã lạc đề, và nó <i>làm loãng</i> ngữ cảnh khiến LLM trả lời kém đi — hiện tượng
"lạc giữa đống tài liệu".</div>
<p>Thực dụng hơn: đặt <b>ngưỡng điểm tương đồng</b> thay vì K cố định — lấy mọi đoạn trên
ngưỡng, không có đoạn nào đạt thì trả lời "không tìm thấy".</p>
</div></div>
<div class="qa" id="q8"><div class="q"><span class="n">8</span>
Chọn mô hình embedding thế nào? Tiếng Việt có ổn không?</div>
<div class="a">
<p>Ba tiêu chí: <b>hỗ trợ tiếng Việt</b>, <b>số chiều</b>, <b>chạy nội bộ hay gọi API</b>.</p>
<table>
<tr><th>Nhóm</th><th>Ví dụ</th><th>Ghi chú</th></tr>
<tr><td>API thương mại</td><td>OpenAI <code>text-embedding-3</code>, Cohere</td>
<td>Chất lượng tốt, nhưng <b>tài liệu phải gửi ra ngoài</b></td></tr>
<tr><td>Đa ngữ, chạy nội bộ</td><td>multilingual-e5, BGE-M3</td>
<td>Tiếng Việt khá tốt, chạy được trên máy công ty</td></tr>
<tr><td>Chuyên tiếng Việt</td><td>PhoBERT và các bản fine-tune</td>
<td>Cần đánh giá lại trên chính dữ liệu của mình</td></tr>
</table>
<div class="note bad"><b>Lưu ý bắt buộc:</b> đổi mô hình embedding thì
<b>phải index lại toàn bộ kho</b>. Vector của mô hình này không so sánh được với vector của
mô hình khác. Nên chọn kỹ ngay từ đầu.</div>
<p>Với dữ liệu nội bộ nhạy cảm, nhóm "chạy nội bộ" thường là lựa chọn duy nhất khả thi.</p>
</div></div>
<div class="qa" id="q9"><div class="q"><span class="n">9</span>
Bắt buộc phải có Vector DB riêng không?</div>
<div class="a">
<p><b>Không.</b> Chọn theo quy mô:</p>
<table>
<tr><th>Quy mô</th><th>Giải pháp</th><th>Ghi chú</th></tr>
<tr><td>&lt; 100k vector</td><td>FAISS, Chroma, hoặc file numpy</td>
<td>Không cần dựng thêm dịch vụ</td></tr>
<tr><td>Đã có PostgreSQL</td><td><code>pgvector</code></td>
<td>Dùng luôn DB sẵn có — thường là lựa chọn tốt nhất</td></tr>
<tr><td>Triệu vector trở lên</td><td>Milvus, Qdrant, Weaviate</td>
<td>Cần index ANN chuyên dụng</td></tr>
<tr><td>Không muốn tự vận hành</td><td>Pinecone</td>
<td>Dịch vụ đám mây, dữ liệu ra ngoài</td></tr>
</table>
<p>Ví dụ trong slide — 100 file PDF ra 20.000 vector — <b>hoàn toàn không cần</b> Vector DB
chuyên dụng. FAISS trên một máy là đủ và nhanh.</p>
</div></div>
<h2>Nhóm 3 — Chất lượng truy hồi</h2>
<div class="qa" id="q10"><div class="q"><span class="n">10</span>
Chỉ tìm theo vector đã đủ chưa?</div>
<div class="a">
<figure>
<svg viewBox="0 0 720 168" role="img" aria-label="Hybrid search và rerank">
<rect x="14" y="52" width="98" height="42" rx="6" fill="#fff" stroke="#4A90D9"/>
<text x="63" y="70" font-size="12" text-anchor="middle">Câu hỏi</text>
<text x="63" y="85" font-size="10" text-anchor="middle" fill="#5A6675">của user</text>
<path d="M116 73 h26" stroke="#5A6675" stroke-width="1.6" marker-end="url(#a2)"/>
<rect x="146" y="20" width="128" height="42" rx="6" fill="#EAF2FC" stroke="#1565C0"/>
<text x="210" y="38" font-size="12" text-anchor="middle" fill="#0A4EA3">Tìm theo vector</text>
<text x="210" y="52" font-size="10" text-anchor="middle" fill="#5A6675">bắt được ý nghĩa</text>
<rect x="146" y="84" width="128" height="42" rx="6" fill="#FDF3E3" stroke="#B26A00"/>
<text x="210" y="102" font-size="12" text-anchor="middle" fill="#8A5000">Tìm theo từ khoá</text>
<text x="210" y="116" font-size="10" text-anchor="middle" fill="#5A6675">bắt mã, tên riêng</text>
<path d="M278 41 h20 v32" stroke="#5A6675" stroke-width="1.6" fill="none"/>
<path d="M278 105 h20 v-32" stroke="#5A6675" stroke-width="1.6" fill="none"
marker-end="url(#a2)"/>
<rect x="318" y="52" width="104" height="42" rx="6" fill="#fff" stroke="#4A90D9"/>
<text x="370" y="70" font-size="12" text-anchor="middle">Gộp kết quả</text>
<text x="370" y="85" font-size="10" text-anchor="middle" fill="#5A6675">~30 đoạn</text>
<path d="M426 73 h26" stroke="#5A6675" stroke-width="1.6" marker-end="url(#a2)"/>
<rect x="456" y="52" width="110" height="42" rx="6" fill="#1565C0"/>
<text x="511" y="70" font-size="12" text-anchor="middle" fill="#fff">Rerank</text>
<text x="511" y="85" font-size="10" text-anchor="middle" fill="#D6E7F8">chấm lại điểm</text>
<path d="M570 73 h26" stroke="#5A6675" stroke-width="1.6" marker-end="url(#a2)"/>
<rect x="600" y="52" width="104" height="42" rx="6" fill="#E8F5EC" stroke="#1B7A3D"/>
<text x="652" y="70" font-size="12" text-anchor="middle" fill="#14612F">Top 5 tinh</text>
<text x="652" y="85" font-size="10" text-anchor="middle" fill="#5A6675">đưa cho LLM</text>
<defs><marker id="a2" markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto">
<path d="M0 0 L7 3.5 L0 7 z" fill="#5A6675"/></marker></defs>
</svg>
<figcaption>Hai cách tìm bù khuyết cho nhau, rồi lọc lại một lần nữa.</figcaption>
</figure>
<p><b>Chưa đủ.</b> Vector giỏi bắt ý nghĩa nhưng <b>dở với mã số, tên riêng, ký hiệu</b> —
hỏi "điều 7.5.3" hay "mã lỗi FN0101" thì tìm từ khoá lại chính xác hơn hẳn.</p>
<p>Hai cải tiến gần như luôn đáng làm:</p>
<ul>
<li><b>Hybrid search</b> — chạy song song vector + từ khoá (BM25), gộp kết quả.</li>
<li><b>Rerank</b> — lấy ~30 đoạn rồi dùng mô hình cross-encoder chấm lại, giữ 5 đoạn tốt nhất.
Đây thường là <b>cải thiện lớn nhất</b> với chi phí nhỏ nhất.</li>
</ul>
</div></div>
<div class="qa" id="q11"><div class="q"><span class="n">11</span>
Câu hỏi cần nối nhiều tài liệu (multi-hop) thì sao?</div>
<div class="a">
<p>Slide đã nêu đúng đây là điểm yếu. Ví dụ: <i>"Nhân viên nào ký hợp đồng với nhà cung cấp
có doanh số cao nhất năm ngoái?"</i> — cần tra bảng doanh số trước, rồi mới tra hợp đồng.</p>
<p>RAG một lượt sẽ hỏng, vì một lần tra không thể ra cả hai. Ba hướng xử lý:</p>
<ul>
<li><b>Tra nhiều vòng (agentic RAG)</b> — cho LLM tự quyết định tra tiếp, dùng kết quả vòng
trước làm câu truy vấn vòng sau.</li>
<li><b>Tách câu hỏi</b> — chia thành các câu con, tra từng câu, rồi tổng hợp.</li>
<li><b>Knowledge graph</b> — dựng sẵn quan hệ giữa các thực thể để đi theo liên kết thay vì
tra lại từ đầu. Đây chính là ý tưởng của GraphRAG.</li>
</ul>
</div></div>
<h2>Nhóm 4 — Vận hành</h2>
<div class="qa" id="q12"><div class="q"><span class="n">12</span>
Context window đã tới 1 triệu token — còn cần RAG không?</div>
<div class="a">
<p><b>Vẫn cần</b>, vì ba lý do:</p>
<ul>
<li><b>Chi phí</b> — nhét 500k token vào mỗi câu hỏi thì mỗi lượt hỏi tốn gấp hàng trăm lần
so với nhét 5 đoạn. Nhân với số lượt hỏi mỗi ngày.</li>
<li><b>Độ trễ</b> — đọc 500k token mất hàng chục giây.</li>
<li><b>Quy mô</b> — kho tài liệu doanh nghiệp thường vài chục triệu token, vượt xa mọi
context window.</li>
</ul>
<div class="note">Thêm nữa, độ chính xác <b>giảm khi ngữ cảnh quá dài</b> — mô hình hay bỏ sót
thông tin nằm ở giữa. Đưa 5 đoạn đúng thường cho kết quả tốt hơn đưa cả cuốn sách.</div>
<p>Context dài <i>có</i> chỗ dùng: khi tổng tài liệu nhỏ (vài chục trang) và bạn muốn giải pháp
đơn giản nhất — lúc đó bỏ RAG cho gọn là hợp lý.</p>
</div></div>
<div class="qa" id="q13"><div class="q"><span class="n">13</span>
Chi phí thực tế bao nhiêu?</div>
<div class="a">
<p>Tách làm hai phần, và phần đắt <b>không</b> phải phần người ta hay lo:</p>
<table>
<tr><th>Khoản</th><th>Khi nào phát sinh</th><th>Mức độ</th></tr>
<tr><td>Embedding tài liệu</td><td>Một lần lúc index + khi tài liệu đổi</td>
<td><b>Rẻ</b> — embedding rẻ hơn LLM hàng chục lần</td></tr>
<tr><td>Lưu trữ vector</td><td>Liên tục</td><td>Nhỏ, trừ khi kho cực lớn</td></tr>
<tr><td>Embedding câu hỏi</td><td>Mỗi lượt hỏi</td><td>Không đáng kể</td></tr>
<tr><td><b>LLM sinh câu trả lời</b></td><td>Mỗi lượt hỏi</td>
<td><b>Chiếm phần lớn chi phí</b></td></tr>
</table>
<p>Vì vậy giảm chi phí RAG thực chất là <b>giảm số token đưa vào LLM</b> — tức chọn top-K
gọn và đoạn sạch, chứ không phải tiết kiệm ở khâu embedding.</p>
</div></div>
<div class="qa" id="q14"><div class="q"><span class="n">14</span>
RAG làm chậm thêm bao nhiêu?</div>
<div class="a">
<p>Bước tra thường tốn <b>vài chục tới vài trăm mili-giây</b>: embedding câu hỏi + tìm trong
vector DB. Có rerank thì cộng thêm chút nữa.</p>
<p>So với thời gian LLM sinh câu trả lời (thường vài giây), phần này <b>gần như không đáng kể</b>.</p>
<div class="note warn">Slide ghi "ứng dụng real-time cần &lt; 100ms" thì nên cẩn trọng —
đúng, nhưng lúc đó nút thắt là <b>LLM</b>, không phải bước tra. Nếu cần dưới 100ms thì
bản thân việc gọi LLM đã không khả thi rồi.</div>
</div></div>
<div class="qa" id="q15"><div class="q"><span class="n">15</span>
Tài liệu sửa thì cập nhật thế nào?</div>
<div class="a">
<p>Chỉ cần <b>index lại phần thay đổi</b>, không đụng tới mô hình:</p>
<ul>
<li>File sửa → xoá vector cũ của file đó, embedding lại, ghi vector mới.</li>
<li>File xoá → xoá vector tương ứng.</li>
<li>File mới → embedding và thêm vào.</li>
</ul>
<p>Cách làm thực dụng: lưu kèm <b>hash nội dung</b> mỗi file, chạy định kỳ, chỉ xử lý file
có hash đổi. Vài giây cho một lần cập nhật thông thường.</p>
<div class="note bad">Ngoại lệ duy nhất phải làm lại toàn bộ: <b>đổi mô hình embedding</b>
hoặc <b>đổi cách chia đoạn</b>.</div>
</div></div>
<div class="qa" id="q16"><div class="q"><span class="n">16</span>
Đo chất lượng RAG bằng gì? Làm sao biết là tốt?</div>
<div class="a">
<p>Điểm mấu chốt: <b>đo tách hai khâu</b>, vì hỏng ở đâu thì sửa ở đó khác nhau.</p>
<table>
<tr><th>Khâu</th><th>Đo gì</th><th>Hỏng thì sửa gì</th></tr>
<tr><td><b>Truy hồi</b></td><td>Đoạn đúng có nằm trong top-K không?</td>
<td>Chia đoạn, mô hình embedding, hybrid, rerank</td></tr>
<tr><td><b>Sinh câu trả lời</b></td><td>Câu trả lời có bám vào đoạn đã lấy không?</td>
<td>Prompt, model, yêu cầu trích nguồn</td></tr>
</table>
<p>Cách làm tối thiểu mà hiệu quả: dựng <b>bộ 50–100 câu hỏi mẫu có đáp án đúng</b> lấy từ
người dùng thật. Mỗi lần chỉnh tham số thì chạy lại bộ đó và so điểm.</p>
<div class="note">Không có bộ câu hỏi mẫu thì mọi tinh chỉnh chỉ là cảm tính — đây là việc
nên làm ngay từ đầu, trước cả khi tối ưu.</div>
</div></div>
<div class="qa" id="q17"><div class="q"><span class="n">17</span>
Phân quyền tài liệu xử lý ra sao? Người A không được xem tài liệu của phòng B.</div>
<div class="a">
<p>Đây là câu hay bị bỏ quên tới lúc triển khai thật mới lộ ra.</p>
<p><b>Nguyên tắc: lọc quyền ở bước truy hồi, không phải ở bước trả lời.</b> Tuyệt đối không
dựa vào việc nhắc LLM "đừng nói về tài liệu này" — không đáng tin.</p>
<ul>
<li>Mỗi vector lưu kèm <b>metadata quyền</b> (phòng ban, mức mật, danh sách người xem).</li>
<li>Khi tra, lọc theo quyền của người hỏi <b>ngay trong truy vấn</b>.</li>
<li>Tài liệu ngoài quyền thì không bao giờ vào được ngữ cảnh của LLM.</li>
</ul>
<div class="note bad">Rủi ro thường gặp: một đoạn trích chứa thông tin mật lọt vào ngữ cảnh,
LLM tóm tắt lại và <b>rò rỉ gián tiếp</b> dù không trích nguyên văn.</div>
</div></div>
<h2>Nhóm 5 — Về dự án Cowork-Local</h2>
<p class="lead">Nhóm này gần như chắc chắn được hỏi, vì slide 17 đã tự nêu ra.</p>
<div class="qa" id="q18"><div class="q"><span class="n">18</span>
Vậy Cowork-Local đã có RAG chưa?</div>
<div class="a">
<p>Trả lời thẳng như slide 17 đã viết: <b>chưa có RAG theo nghĩa đầy đủ.</b></p>
<figure>
<svg viewBox="0 0 720 150" role="img" aria-label="Ba mức nạp ngữ cảnh">
<rect x="10" y="24" width="222" height="104" rx="8" fill="#FDF3E3" stroke="#B26A00"/>
<text x="121" y="48" font-size="13" font-weight="700" text-anchor="middle" fill="#8A5000">
Mức 1 — Nạp thủ công</text>
<text x="121" y="70" font-size="11.5" text-anchor="middle" fill="#2B3542">Đính kèm file, dán link,</text>
<text x="121" y="86" font-size="11.5" text-anchor="middle" fill="#2B3542">Instructions của project</text>
<text x="121" y="110" font-size="11" text-anchor="middle" fill="#8A5000">Người dùng tự chọn</text>
<rect x="248" y="24" width="222" height="104" rx="8" fill="#EAF2FC" stroke="#1565C0"/>
<text x="359" y="48" font-size="13" font-weight="700" text-anchor="middle" fill="#0A4EA3">
Mức 2 — Tra theo cấu trúc</text>
<text x="359" y="70" font-size="11.5" text-anchor="middle" fill="#2B3542">GraphRAG: sơ đồ file,</text>
<text x="359" y="86" font-size="11.5" text-anchor="middle" fill="#2B3542">lớp, hàm, quan hệ</text>
<text x="359" y="110" font-size="11" text-anchor="middle" fill="#0A4EA3">AI tự tra — đang ở đây</text>
<rect x="486" y="24" width="224" height="104" rx="8" fill="#F2F4F7" stroke="#CBD5E1"
stroke-dasharray="5 4"/>
<text x="598" y="48" font-size="13" font-weight="700" text-anchor="middle" fill="#5A6675">
Mức 3 — Tra theo ngữ nghĩa</text>
<text x="598" y="70" font-size="11.5" text-anchor="middle" fill="#5A6675">Embedding + Vector DB</text>
<text x="598" y="86" font-size="11.5" text-anchor="middle" fill="#5A6675">tìm theo nghĩa</text>
<text x="598" y="110" font-size="11" text-anchor="middle" fill="#8A94A3">chưa có</text>
</svg>
<figcaption>Dự án đang ở mức 2. Mức 3 mới là RAG như trình bày ở phần đầu.</figcaption>
</figure>
<p>Cách nói an toàn khi bị hỏi vặn: <i>"Hiện tại là truy xuất theo cấu trúc, chưa phải truy xuất
theo ngữ nghĩa. Phần trình bày hôm nay là kiến thức nền cho bước tiếp theo."</i></p>
</div></div>
<div class="qa" id="q19"><div class="q"><span class="n">19</span>
"GraphRAG" của dự án có phải GraphRAG của Microsoft không?</div>
<div class="a">
<div class="note warn"><b>Câu này rất dễ bị hỏi và dễ gây hiểu nhầm — nên chủ động làm rõ trước.</b></div>
<table>
<tr><th></th><th>GraphRAG (Microsoft)</th><th>GraphRAG trong Cowork-Local</th></tr>
<tr><td>Đồ thị chứa gì</td><td>Thực thể và quan hệ do <b>LLM trích</b> từ nội dung</td>
<td>File, lớp, hàm và liên kết import</td></tr>
<tr><td>Dựng bằng gì</td><td>Gọi LLM nhiều lượt, tốn chi phí</td>
<td>Phân tích cú pháp mã nguồn, <b>không tốn phí gọi AI</b></td></tr>
<tr><td>Trả lời câu hỏi</td><td>Đi theo quan hệ + tóm tắt theo cụm</td>
<td>Đọc sơ đồ và nội dung file liên quan</td></tr>
</table>
<p><b>Cùng tên, khác bản chất.</b> Slide của bạn mô tả đúng cái thứ hai — "quét file, ghi nhận
mỗi file có class/hàm gì và liên kết với file nào".</p>
<p>Nói rõ điểm này lại là <b>lợi thế</b>: cách của dự án <i>rẻ và nhanh hơn nhiều</i> vì không
phải gọi LLM để dựng đồ thị.</p>
</div></div>
<div class="qa" id="q20"><div class="q"><span class="n">20</span>
Muốn nâng lên RAG đầy đủ thì cần làm gì?</div>
<div class="a">
<p>Bốn việc, xếp theo thứ tự nên làm:</p>
<table>
<tr><th>#</th><th>Việc</th><th>Quyết định phải chốt</th></tr>
<tr><td>1</td><td>Chọn mô hình embedding</td>
<td>Chạy nội bộ hay gọi API — quyết định này ràng buộc mọi thứ sau, và
<b>đổi về sau là phải index lại toàn bộ</b></td></tr>
<tr><td>2</td><td>Chia đoạn tài liệu</td>
<td>Cắt theo cấu trúc (mục, điều, hàm) trước khi cắt theo độ dài</td></tr>
<tr><td>3</td><td>Chọn nơi lưu vector</td>
<td>Quy mô hiện tại chỉ cần FAISS hoặc <code>pgvector</code></td></tr>
<tr><td>4</td><td>Dựng bộ câu hỏi đánh giá</td>
<td>50–100 câu có đáp án đúng — <b>làm trước khi tối ưu</b></td></tr>
</table>
<div class="note ok"><b>Điểm mạnh sẵn có:</b> dự án đã có sẵn khái niệm <i>project</i> với
thư mục riêng và phân tách dữ liệu theo project. Đó chính là ranh giới phân quyền tự nhiên
cho câu 17 — thứ mà nhiều dự án phải làm lại từ đầu.</div>
</div></div>
<h2>Ba câu nên chuẩn bị sẵn câu trả lời</h2>
<div class="note bad"><b>1. "RAG có hết bịa không?"</b> → Không, chỉ giảm. Nói thẳng và nêu
cách giảm: bắt trích nguồn, cho phép trả lời "không tìm thấy".</div>
<div class="note bad"><b>2. "GraphRAG này có phải GraphRAG kia không?"</b> → Không, cùng tên
khác bản chất. Chủ động nói trước khi bị hỏi.</div>
<div class="note bad"><b>3. "Vậy dự án đã có RAG chưa?"</b> → Chưa đủ. Đang ở mức truy xuất
theo cấu trúc, chưa có truy xuất theo ngữ nghĩa.</div>
</div>
</body>
</html>
+233
View File
@@ -0,0 +1,233 @@
# BÁO CÁO KẾT QUẢ — TEAM DUY: EPIC R01, R03, R04
* **Dự án**: Cowork Local (Cowork-Local BamBOO)
* **Team**: 🔵 Team Duy — Core AI, Routing, Turn Runtime & Testing (Tech Lead)
* **Nhánh**: `feature/deltateam/refactor-plan`
* **Thời gian thực hiện**: 21/08/2026, 09:56 ➔ 10:56
* **Ngày báo cáo**: 21/08/2026
* **Tài liệu gốc**: `Feature_Architecture_Proposal.md`, `Refactoring_Checklist.md`, `DeltaTeam_prompt.md`
---
## 1. Tóm tắt điều hành
Hoàn tất **16/16 task** của 3 EPIC được giao trong đợt này: **R01** (nền tảng kiến trúc & lưới an toàn), **R03** (hợp nhất provider & routing), **R04** (vòng đời turn hội thoại). Toàn bộ đã commit và push lên nhánh.
| Chỉ số | Kết quả |
| :--- | :--- |
| Task hoàn thành | **16/16** (R01: 5, R03: 6, R04: 5) |
| Commit | 5 |
| File thay đổi | 48 (37 file mới, 11 file sửa) |
| Dòng code | +5.843 / −225 |
| Test | **243 pass** / 44s |
| Test suite nhanh (unit + contract + characterization + routing) | **218 pass / 1,22s** |
| CASAN Check 3 (`scripts/check_imports.py`) | **PASS** — 0 Qt import trong `domain/`, `application/` |
| File production > 400 dòng | **0** |
**3 lỗi thật được phát hiện và sửa trong quá trình làm** (chi tiết mục 5) — trong đó 1 lỗi deadlock sẽ làm treo ứng dụng ngay ở tin nhắn đầu tiên.
---
## 2. Kết quả theo từng EPIC
### 🔹 EPIC R01 — Architecture Foundation & Characterization (5/5)
| Task | Sản phẩm | Ghi chú |
| :--- | :--- | :--- |
| R01-T01 | `docs/architecture/ADR-001-layered-architecture.md` | Định nghĩa 4 tầng, chiều phụ thuộc, 6 quy tắc bất biến I1–I6, chiến lược di trú Strangler Fig |
| R01-T02 | `tests/fakes/fake_provider.py`, `fake_tool_executor.py` | Test double chạy offline, kịch bản hoá, ghi lại mọi lời gọi |
| R01-T03 | `scripts/check_imports.py` (239 dòng) | Quét AST, bắt cả import tương đối (`from ...ui import x`) và import trong thân hàm |
| R01-T04 | `tests/characterization/test_run_cowork.py` | **13 test** chụp snapshot hành vi hiện tại của `run_cowork` trước khi R04 đụng vào |
| R01-T05 | `docs/architecture/dormant-code.md` | Quét đồ thị import: 43 module "không ai import" ➔ xác minh còn **6 hạng mục chết thật (~1.887 dòng)** |
**Điểm đáng chú ý ở R01-T03**: dùng AST thay vì `grep` là bắt buộc — trong repo có nhiều docstring nhắc tên `PySide6` một cách hợp lệ, `grep` sẽ báo nhầm và đội sẽ học cách tắt cổng kiểm duyệt.
**Điểm đáng chú ý ở R01-T05**: 43 module không có importer **không** đồng nghĩa 43 module chết. Sau xác minh thủ công: `__main__.py` là entry point, `mcp_servers/ms365_server.py` chạy bằng subprocess (`state.py:285`), 34 file `tools/check_*.py` là dev tooling chạy tay. Chỉ 6 hạng mục là dormant thật.
### 🔹 EPIC R03 — Model Providers & Routing (6/6)
| Task | Sản phẩm | Ghi chú |
| :--- | :--- | :--- |
| R03-T01 | `tests/contracts/test_providers.py` | **29 contract test**; chạy được cả 2 adapter thật mà **không cần mạng** nhờ thay `Provider._request` bằng SSE đóng hộp |
| R03-T02 | `domain/models/provider_descriptor.py`, `infrastructure/providers/provider_registry.py` | Gom 3 nơi khai báo provider về 1 chỗ |
| R03-T03 | `application/model_routing/routing_application_service.py` | Pure Python, 4 chế độ: Off / Auto / Manual / **Fallback (mới)** |
| R03-T04, T05 | `ui/chat_panel.py`, `ui/co4e_tab.py`, `ui/folder_tab.py` | Gỡ 3 bản sao logic routing |
| R03-T06 | `infrastructure/telemetry/usage_sink.py` | Tách ghi nhận token usage khỏi provider |
**Vấn đề gốc đã giải quyết** — cùng một thuật toán routing tồn tại **3 bản gần giống nhau**:
```
ui/chat_panel.py::_apply_routing (~45 dòng)
ui/co4e_tab.py::_apply_co4e_routing (~38 dòng)
ui/folder_tab.py::_ai_apply_routing (~42 dòng)
```
Cả 3 đều nằm trong widget Qt ➔ **không thể test nếu không dựng cửa sổ**, và đã bắt đầu lệch nhau (mỗi bản xác định "model hiện tại" một kiểu). Nay cả 3 chỉ còn gọi `ctx.routing_application().route_turn(...)` + một callback xác nhận.
**Chế độ Fallback (mới)**: giữ nguyên model người dùng chọn, **chỉ đổi sau khi model đó lỗi**. Đây là chế độ người dùng cần khi họ tin lựa chọn của mình nhưng vẫn muốn lượt chat sống sót qua sự cố nhà cung cấp.
**Bộ từ vựng mode**: trước đây tuple `("off", "auto", "manual")` bị lặp ở **4 chỗ** (`config.py` × 2, `state.py` × 2). Thêm một mode mà quên một chỗ sẽ **âm thầm hạ lựa chọn của người dùng về "off"**. Nay tập trung vào `normalize_mode()` / `is_valid_mode()`.
### 🔹 EPIC R04 — Agent Runtime & Conversation Service (5/5)
| Task | Sản phẩm | Ghi chú |
| :--- | :--- | :--- |
| R04-T01 | `domain/agents/conversation_execution_request.py` | Frozen dataclass, chụp toàn bộ input của 1 turn tại thời điểm submit |
| R04-T02 | `domain/agents/agent_event.py` (370 dòng) | **13 event có kiểu** thay cho dict không kiểu, kèm cầu nối 2 chiều |
| R04-T03 | `application/conversations/conversation_application_service.py` | Điều phối vòng đời turn, không import Qt |
| R04-T04 | `ui/cowork_tab.py::build_job` | Chuyển sang snapshot + service |
| R04-T05 | `core/task_executors.py::_run_agent` | Chuyển sang **cùng** service (trước đây là bản lắp ráp thứ hai, hơi khác) |
**Vấn đề gốc đã giải quyết** — closure trong `build_job` đọc state của widget **từ trong worker thread**:
```python
def job(worker):
provider = self.build_provider() # đọc combo box
proj_ctx = project_context_text(load_project(project_id))
```
Người dùng có thể đổi model, đổi workspace, sửa chỉ dẫn project **trong lúc turn đang chạy**. Turn khi đó chạy trên hỗn hợp state cũ + mới, và hỗn hợp nào phụ thuộc vào thời điểm luồng — đúng loại bug tái hiện mỗi tuần một lần và không bao giờ tái hiện trong test.
**`TurnCompletedEvent`** là tín hiệu kết thúc turn mà engine cũ **hoàn toàn không có**: hiện tại mọi consumer suy ra "xong" từ việc worker thread kết thúc, nên **turn bị huỷ và turn thất bại trông giống hệt nhau** với giao diện.
---
## 3. Kiến trúc sau refactor
```text
presentation/ ui/chat_panel.py, ui/co4e_tab.py, ui/folder_tab.py, ui/cowork_tab.py
│ (chỉ dựng UI, mở dialog xác nhận, render thông báo)
▼
application/ model_routing/routing_application_service.py ← 4 mode routing
conversations/conversation_application_service.py ← vòng đời turn
│ (100% pure Python — cổng kiểm duyệt tự động chặn import Qt)
▼
domain/ agents/conversation_execution_request.py ← snapshot bất biến
agents/agent_event.py ← 13 event có kiểu
models/provider_descriptor.py ← catalog provider
▲
infrastructure/ providers/provider_registry.py telemetry/usage_sink.py
```
**Nguyên tắc di trú (ADR-001 mục 4)**: **không viết lại engine**. `core/chat_agent.py::run_cowork` và `core/routing/*` (2.263 dòng, 79 test đang xanh) vẫn là engine bên dưới; tầng application chỉ sở hữu phần trước đây bị trộn vào UI. Nhờ vậy `pytest` luôn xanh giữa các bước và một team có thể merge mà không phải chờ team khác.
---
## 4. Bằng chứng kiểm thử
### Phân bố test
| Suite | Số test | Thời gian | Vai trò |
| :--- | ---: | ---: | :--- |
| `tests/unit/` | 97 | | Logic thuần, không Qt/mạng |
| `tests/contracts/` | 29 | | Mọi provider phải thoả cùng bộ cam kết |
| `tests/characterization/` | 13 | | Chốt hành vi hiện tại của `run_cowork` |
| `tests/routing/` | 79 | | Có sẵn từ trước, vẫn xanh |
| **Cộng 4 suite nhanh** | **218** | **1,22s** | ✅ đạt CASAN "A — unit < 1s" |
| `tests/integration/` | 25 | 42s | Widget Qt thật (offscreen) + provider kịch bản hoá |
| **Tổng** | **243** | **44s** | |
### Đối chiếu Definition of Done (7 tiêu chí, `DeltaTeam_prompt.md`)
| # | Tiêu chí | Kết quả |
| :--- | :--- | :--- |
| 1 | Mọi file < 400 dòng | ✅ Lớn nhất: `agent_event.py` 370 dòng |
| 2 | 0 import Qt trong `domain/`, `application/` | ✅ `check_imports.py` PASS |
| 3 | Comment tiếng Anh ở mọi khối sửa/mới | ✅ Docstring + giải thích **lý do**, không chỉ mô tả code |
| 4 | Có unit/contract test, pass 100% < 1s | ✅ 218 test / 1,22s |
| 5 | Không hồi quy | ✅ 79 test routing có sẵn vẫn xanh |
| 6 | Ghi Start/End vào Checklist | ✅ 16 task đã tick kèm mốc thời gian |
| 7 | Cổng CASAN | ⚠️ `run_quality_gate.py` thuộc **R10-T02**, chưa viết. Check 3 đã có và PASS |
### Ba đường code đã sửa nhưng ban đầu chưa được thực thi
Sau khi hoàn tất 16 task, rà soát lại phát hiện 3 đường code đã bị sửa nhưng **không test nào chạy qua**. Đã bổ sung **18 test**:
| Đường code | Rủi ro nếu bỏ qua | Test bổ sung |
| :--- | :--- | ---: |
| `task_executors._run_agent` | Autosave History có thể đóng băng ở tin nhắn đầu | 7 |
| `_apply_co4e_routing` / `_ai_apply_routing` | Mới chỉ import được, chưa từng gọi hàm | 11 |
| `confirm_switch(decision)` Manual mode | Thiếu field ➔ **nổ bên trong modal**, nơi khó phát hiện nhất | (nằm trong 11 ở trên) |
---
## 5. Ba lỗi thật phát hiện trong quá trình làm
### 🔴 Lỗi 1 — Deadlock khi khởi tạo routing service
`AppContext.routing_application()` giữ `_routing_lock` rồi gọi `routing()`, vốn cũng lấy **chính lock đó**. `threading.Lock` không reentrant ➔ **treo cứng ngay ở tin nhắn đầu tiên**, không có thông báo lỗi.
*Sửa*: tách `_routing_app_lock` riêng, và resolve engine **trước khi** lấy lock.
### 🟠 Lỗi 2 — Event `notice` bị cầu nối nuốt mất
Bản đầu của `agent_event.py` liệt kê 12 loại event nhưng **thiếu `notice`**. Trong khi đó `notice` được phát ra từ 3 nơi trên đường chạy bình thường:
* `core/agent_security.py` — yêu cầu/lệnh bị Agent Security **chặn**
* `core/context_budget.py` — hội thoại vừa bị tự động nén
* Bộ đọc file đính kèm — file không xử lý được, và tiến độ "đang đọc trang X/Y"
Cầu nối bỏ qua event không nhận diện được (đúng thiết kế, để engine có thể thêm event mới) — nên **người dùng sẽ không bao giờ thấy cảnh báo bảo mật**, hoàn toàn im lặng.
*Sửa*: thêm `NoticeEvent`, **và** thêm test quét mã nguồn engine tìm mọi tag `emit({"type": ...})` rồi bắt lỗi nếu có tag nào chưa có event tương ứng — biến sự im lặng thành test đỏ.
### 🟡 Lỗi 3 — Test đang chạy trên checkout khác
`tests/routing/conftest.py` đẩy thư mục cha vào `sys.path`. Vì thư mục checkout tên là `cowork_local_gitea` (không phải `cowork_local`), lệnh `import cowork_local` **ăn nhầm sang `Desktop\cowork_local`** — một bản checkout khác. Suite báo xanh trên mã nguồn **không phải nhánh đang review**.
*Sửa*: `tests/conftest.py` nạp `__init__.py` theo đường dẫn tuyệt đối và đăng ký vào `sys.modules` trước mọi test.
---
## 6. Cải thiện phụ (không nằm trong yêu cầu task)
| Cải thiện | Ảnh hưởng |
| :--- | :--- |
| `ProviderRegistry.build()` đóng dấu `descriptor.id` lên instance | Sửa việc usage của `ollama` / `github_copilot` / `codex` bị ghi nhận nhầm thành `openai_compat` trên Dashboard. **Chưa nối vào production** — xem mục 7. |
| `ProviderRegistry.build()` copy config trước khi ghi | Trước đây một model do routing chọn có thể ghi đè lên default đã lưu của người dùng |
| `UsageTrackerSink` ghi log ở mức debug khi thất bại | Trước là `except: pass` — mất sạch lý do khi Dashboard hỏng |
| `estimate_tokens` được chốt bằng test so với `core.usage_tracker` | Bảo đảm việc tách telemetry **không làm lệch một con số nào** |
---
## 7. Còn nợ & cần quyết định
| # | Nội dung | Người quyết |
| :--- | :--- | :--- |
| 1 | **`ProviderRegistry` chưa nối vào `state.build_provider_for`** (vẫn dùng `providers/factory.py`). Nối vào sẽ sửa lỗi quy kết usage ở mục 6, **nhưng đổi cách gom dữ liệu lịch sử trên Dashboard**. | Team Duy + PO |
| 2 | **Mode `fallback` chưa có trên toggle UI** — config và service đã hỗ trợ đầy đủ; widget `RoutingToggle` thuộc R08. | Team Duy (R08) |
| 3 | **Đã sửa 2 dòng trong `config.py`** (`routing_mode_for`, `set_routing_mode_for`) để dùng chung bộ từ vựng mode. File này Team Nam đang refactor ở R02-T02. | ⚠️ **Cần báo Team Nam** |
| 4 | **Circular import** `core/model_pricing.py` ↔ `core/usage_tracker.py` chưa xử lý (task ngày 28/08). | Team Duy |
| 5 | **2 test đỏ có sẵn từ trước**: `config.py:108` hardcode `sandbox_pw = "quandh14"` ➔ `tests/test_config_security.py`. Thuộc **EPIC R02 / Team Nam**. | 🟣 Team Nam |
| 6 | `tests/integration/test_routing_surfaces.py` mất 41s do dựng `Co4ETab`/`FolderTab`. Nên gắn marker `slow` khi làm R10. | Team Duy (R10) |
---
## 8. Phạm vi chưa kiểm thử
Nêu rõ để tránh hiểu nhầm mức độ bảo đảm:
* **Chưa mở ứng dụng bằng tay** — mới chạy widget headless (`QT_QPA_PLATFORM=offscreen`), chưa có ai kiểm tra bằng mắt.
* **Chưa gọi provider thật** — toàn bộ dùng `FakeProvider`, không có lưu lượng mạng.
* **Chưa chạy 34 script `tools/check_*.py`** — các script này tự `sys.path.insert` thư mục cha nên sẽ import nhầm checkout khác (đúng lỗi 3 ở mục 5). Cần sửa chúng ở R10.
---
## 9. Việc kế tiếp của Team Duy
| EPIC | Nội dung | Điều kiện |
| :--- | :--- | :--- |
| **R08** (T01 ➔ T06) | Tách `ui/chat_panel.py` (1.795 dòng) thành 6 widget < 400 dòng | Sẵn sàng bắt đầu — `AgentEvent` (R04-T02) chính là kênh dữ liệu 6 widget con sẽ dùng thay vì đọc trực tiếp state của `ChatPanel` |
| **R10** (T01 ➔ T05) | Testing Pyramid, `run_quality_gate.py`, Contributor Recipes, E2E Smoke | Chờ cả 3 team hoàn tất |
---
## 10. Lịch sử commit
| Commit | Nội dung |
| :--- | :--- |
| `bbc09f6` | feat(R01): architecture foundation, offline fakes and characterization net |
| `96bec97` | feat(R03): unify provider catalogue, routing decisions and usage telemetry |
| `a53163e` | feat(R04): immutable turn snapshot, typed agent events, conversation service |
| `15e1d3e` | test(R03/R04): cover the three code paths that were changed but never executed |
| `67b8d2e` | docs(refactor): correct the Team Duy scope block in the checklist |
+115
View File
@@ -0,0 +1,115 @@
# HỆ THỐNG PROMPT KỸ SƯ TRƯỞNG PYTHON & KIẾN TRÚC SƯ TÁI CẤU TRÚC (TEAM DUY)
Bạn là một **Kỹ sư phần mềm Python Cao cấp (Senior / Staff Python Engineer) & Chuyên gia Kiến trúc Ứng dụng Desktop Local-First**, giữ vai trò Tech Lead thực thi kỹ thuật cho **🔵 Team Duy** trong dự án **Cowork Local (Cowork-Local BamBOO)**.
---
## 🎯 NHIỆM VỤ CỐT LÕI & PHẠM VI SỞ HỮU CỦA TEAM DUY
Nhiệm vụ của bạn là trực tiếp chỉ đạo và thực thi kế hoạch tái cấu trúc mã nguồn theo đúng tài liệu thiết kế kiến trúc `Feature_Architecture_Proposal.md` và cập nhật tiến độ vào file `Refactoring_Checklist.md`.
### 📦 Các Phân Hệ Thư Mục Do Team Duy Quản Lý:
- **Tầng Giao Diện (Presentation)**: `presentation/chat/` (Bóc tách từ `ui/chat_panel.py` và `ui/help_agent_widget.py`).
- **Tầng Nghiệp Vụ (Application)**: `application/conversations/`, `application/model_routing/`.
- **Tầng Miền Dữ Liệu (Domain)**: `domain/agents/`, `domain/models/`.
- **Tầng Hạ Tầng (Infrastructure)**: `infrastructure/providers/`, `infrastructure/telemetry/`.
- **Kiểm Thử & Quản Trị Hệ Thống (Testing & Governance)**: `tests/` (Unit, Contract, Integration, E2E Smoke), `scripts/` (Bộ công cụ kiểm duyệt CASAN Gate), `docs/governance/`.
- **Các EPIC Trọng Tâm**: **R01, R03, R04, R08 (Phân hệ Chat UI: R08-T01 ➔ R08-T06), R10 (Chủ trì chính Testing Pyramid & Phát hành)**.
---
## ⚖️ CÁC QUY TẮC KIẾN TRÚC & NGUYÊN TẮC BẤT BIẾN
1. **Kiến Trúc 4 Tầng Sạch (4-Tier Clean Architecture)**:
```text
presentation/chat/ (PySide6 UI Widgets & Qt Signals)
│
▼
application/conversations/ & application/model_routing/ (Pure Python Orchestration)
│
▼
domain/agents/ & domain/models/ (Pure Python Entities, Events, Descriptors)
▲
│
infrastructure/providers/ & infrastructure/telemetry/ (Adapters, Keyring, Network, Disk)
```
- **QUY TẮC CỐT TỬ**: Tầng `domain/` và `application/` phải là **100% Pure Python**. TUYỆT ĐỐI KHÔNG import `PySide6`, `PyQt*` hay bất kỳ UI widget nào trong 2 tầng này.
2. **Tuân Thủ Tuyệt Đối Cổng Kiểm Duyệt CASAN (CASAN Verification Gate)**:
- **C (Clean Arch)**: Chạy `python scripts/check_imports.py` phải đạt `0 Qt imports in domain and application`.
- **A (Atomic & Secret)**: 0 plaintext API Key/Token trong file cấu hình; 100% keys quản lý qua `SecretStore` (Keyring); ghi tệp an toàn qua `AtomicJsonFile`.
- **S (Single Responsibility)**: **GIỚI HẠN CỨNG: Không có file production nào vượt quá 400 dòng code (LOC)**.
- **A (Automated Tests)**: Bộ test chạy offline hoàn toàn, tốc độ siêu nhanh (< 1 giây cho unit tests), không phụ thuộc mạng hay Qt loop.
- **N (No Regression)**: 100% test pass khi chạy lệnh `pytest tests/`.
3. **Bắt Buộc Comment Code Bằng Tiếng Anh (Mandatory English Comments)**:
- Ở **mỗi dòng hoặc khối code được chỉnh sửa/tạo mới**, bạn **BẮT BUỘC phải viết comment bằng Tiếng Anh** giải thích rõ logic xử lý, cách xử lý ngoại lệ và lý do kỹ thuật/kiến trúc (rationale).
- *Ví dụ mẫu*:
```python
# Extract an immutable execution snapshot to decouple turn lifecycle from PySide6 UI state
request = ConversationExecutionRequest.from_ui_state(session_id=session_id, prompt=prompt)
```
4. **Ghi Nhận Mốc Thời Gian Thực Hiện (Start/End Timestamps)**:
- Trước khi bắt đầu code task nào, phải ghi nhận: `Start: YYYY-MM-DD HH:mm`.
- Sau khi code xong và unit test pass 100%, phải ghi nhận: `End: YYYY-MM-DD HH:mm` và đánh dấu `[x]` vào `Refactoring_Checklist.md`.
5. **An Toàn Đa Luồng (Thread-Safety) & Snapshot Bất Biến**:
- Mọi tiến trình gọi AI và thực thi Tool phải chạy bất đồng bộ trong background thread, không bao giờ làm đơ Main Thread của PySide6.
- Giao diện UI chỉ được cập nhật thông qua Qt Signals/Slots lắng nghe luồng sự kiện `AgentEvent`.
- Luôn đóng gói trạng thái đầu vào thành `ConversationExecutionRequest` bất biến trước khi gửi vào Application Service.
---
## 🛠️ LỘ TRÌNH THỰC THI TỪNG BƯỚC (TEAM DUY)
Khi thực hiện nhiệm vụ, tuân thủ đúng thứ tự 5 giai đoạn sau:
### 📍 Giai Đoạn 1: Thiết Lập Nền Móng Kiến Trúc & Test Bảo Vệ (EPIC R01)
1. `R01-T01`: Soạn thảo `docs/architecture/ADR-001-layered-architecture.md` định nghĩa ranh giới 4 tầng.
2. `R01-T02`: Xây dựng `tests/fakes/fake_provider.py` & `fake_tool_executor.py` phục vụ test offline.
3. `R01-T03`: Viết script phân tích cú pháp AST `scripts/check_imports.py` chặn import Qt trái phép.
4. `R01-T04`: Viết Characterization Tests tại `tests/characterization/test_run_cowork.py` chụp snapshot hàm `core/chat_agent.py::run_cowork`.
5. `R01-T05`: Phân loại và cô lập mã nguồn cũ trong `docs/architecture/dormant-code.md`.
### 📍 Giai Đoạn 2: Chuẩn Hóa Provider & Hợp Nhất Bộ Định Tuyến (EPIC R03)
1. `R03-T01`: Xây dựng bộ Contract Tests chuẩn hóa cho các Provider trong `tests/contracts/test_providers.py`.
2. `R03-T02`: Tạo `domain/models/provider_descriptor.py` và `infrastructure/providers/provider_registry.py`.
3. `R03-T03`: Xây dựng `application/model_routing/routing_application_service.py` (Pure Python) hỗ trợ 4 chế độ: Off, Auto, Manual, Fallback.
4. `R03-T04` & `R03-T05`: Hợp nhất logic routing bị phân tán tại `ui/chat_panel.py#L638`, `ui/co4e_tab.py`, `ui/folder_tab.py` về gọi chung `RoutingApplicationService`.
5. `R03-T06`: Tách bộ ghi nhận token usage thành `infrastructure/telemetry/usage_sink.py`.
### 📍 Giai Đoạn 3: Động Cơ Hội Thoại & Vòng Đời Turn Chat (EPIC R04)
1. `R04-T01`: Định nghĩa frozen dataclass snapshot `domain/agents/conversation_execution_request.py`.
2. `R04-T02`: Định nghĩa các sự kiện có kiểu dữ liệu mạnh trong `domain/agents/agent_event.py` (`TextChunkEvent`, `ToolCallStartedEvent`, `ToolCallFinishedEvent`, `TurnCompletedEvent`, `ErrorEvent`).
3. `R04-T03`: Cài đặt `application/conversations/conversation_application_service.py` điều phối toàn bộ vòng đời turn.
4. `R04-T04` & `R04-T05`: Chuyển đổi `ui/cowork_tab.py` và `core/task_executors.py` sang dùng chung `ConversationApplicationService`.
### 📍 Giai Đoạn 4: Phân Rã God-Widget Màn Hình Chat (EPIC R08 - Phân Hệ Chat)
Bóc tách file khổng lồ `ui/chat_panel.py` (>1.800 dòng) thành 6 widget con chuyên biệt (< 400 dòng/file):
1. `R08-T01`: `presentation/chat/chat_history_widget.py` (Render bong bóng chat, markdown stream, tool cards).
2. `R08-T02`: `presentation/chat/composer_widget.py` (Ô nhập liệu text auto-resize, phím tắt Ctrl+Enter).
3. `R08-T03`: `presentation/chat/attachment_picker.py` (Bộ chọn file, folder, ảnh đính kèm).
4. `R08-T04`: `presentation/chat/audio_recorder_widget.py` (Ghi âm giọng nói & nhận diện văn bản).
5. `R08-T05`: `presentation/chat/chat_output_panel.py` (Panel hiển thị và theo dõi file output trong turn).
6. `R08-T06`: `presentation/chat/chat_panel.py` (Shell container điều phối các widget con và `Floating HelpAgent`).
### 📍 Giai Đoạn 5: Tháp Kiểm Thử, Cổng CI Quality Gate & Smoke Test (EPIC R10 - Chủ Trì Chính)
1. `R10-T01`: Cấu trúc lại thư mục test phân tầng (`tests/unit/`, `tests/contracts/`, `tests/integration/`, `tests/fakes/`).
2. `R10-T02`: Xây dựng bộ script kiểm thử tự động (`scripts/check_imports.py`, `scripts/check_loc.py`, `scripts/audit_security.py`, `scripts/run_quality_gate.py`).
3. `R10-T03`: Cập nhật tài liệu `README.md` và `START_CONTRIBUTING.md` với sơ đồ 4 tầng và hướng dẫn cấu hình Git hook.
4. `R10-T04`: Soạn thảo `docs/governance/contributor-recipes.md` (3 công thức: Thêm Provider mới, Thêm Tool/MCP mới, Thêm Màn hình UI mới).
5. `R10-T05`: Xây dựng bộ kiểm thử khói phát hành `tests/e2e/test_smoke.py` chạy qua headless Qt kiểm tra tự động 5 luồng nghiệp vụ cốt lõi.
---
## 📋 CHECKLIST TIÊU CHUẨN HOÀN THÀNH (DEFINITION OF DONE - DOD)
Trước khi đóng bất kỳ task nào hoặc gửi PR, bạn phải tự kiểm tra 7 tiêu chí sau:
- [ ] 1. **Kích thước file (LOC)**: Mọi file sửa đổi hoặc tạo mới đều **< 400 dòng code**.
- [ ] 2. **Kiến trúc sạch (Clean Arch)**: 0 import `PySide6`/Qt trong `domain/` và `application/` (`python scripts/check_imports.py` pass 100%).
- [ ] 3. **Comment tiếng Anh**: 100% các khối code sửa đổi/tạo mới đều có comment tiếng Anh giải thích logic và lý do kỹ thuật.
- [ ] 4. **Kiểm thử tự động**: Có unit test / contract test tương ứng với tỷ lệ pass 100% trong thời gian < 1 giây.
- [ ] 5. **Không hồi quy lỗi (No Regression)**: Toàn bộ suite test chạy xanh với lệnh `pytest tests/`.
- [ ] 6. **Cập nhật tiến độ**: Đã ghi nhận đầy đủ thời gian `Start` và `End` vào file `Refactoring_Checklist.md`.
- [ ] 7. **Cổng CASAN**: Lệnh `python scripts/run_quality_gate.py` chạy thành công không có bất kỳ cảnh báo vi phạm nào.
File diff suppressed because it is too large Load Diff
+349
View File
@@ -0,0 +1,349 @@
# COWORK LOCAL - BẢNG CHECKLIST TIẾN ĐỘ TÁI CẤU TRÚC (2026)
## (REFACTORING & MIGRATION PROGRESS TRACKER)
* **Dự án**: Cowork Local (Cowork-Local BamBOO)
* **Thời gian thực hiện**: 21/08/2026 ➔ 31/08/2026
* **Đội ngũ phụ trách**:
- 🔵 **Team Duy** (Core AI, Routing, Turn Runtime & Testing Pyramid - Tech Lead)
- 🟣 **Team Nam** (Automation Workflows, Co4E, Monitoring & Shell Governance)
- 🟢 **Team Hoa** (Workspace, Filesystem, Scheduling & Tool Registry)
* **Tài liệu thiết kế kiến trúc gốc**: `Feature_Architecture_Proposal.md`
> [!IMPORTANT]
> ### 📝 QUY ĐỊNH BẮT BUỘC KHI CODE & GHI NHẬN TIẾN ĐỘ (MANDATORY RULES):
> 1. **In-Code Comments in English (Bắt buộc comment tiếng Anh ở mọi dòng/khối code sửa đổi)**:
> - Mỗi khi sửa đổi hoặc viết mới bất kỳ dòng code nào, lập trình viên **bắt buộc phải thêm comment bằng tiếng Anh** giải thích rõ mục đích xử lý, lý do kiến trúc và mối quan hệ giữa các tầng.
> - Tuyệt đối không để code không có chú thích, đặc biệt tại các điểm chuyển đổi DTO, seams và xử lý ngoại lệ.
> 2. **Task Start / End Timestamps (Ghi nhận chính xác ngày giờ bắt đầu và hoàn tất)**:
> - Khi bắt đầu làm một task ➔ Điền mốc thời gian: `Start: YYYY-MM-DD HH:mm`.
> - Khi task hoàn tất (unit test pass 100%) ➔ Điền mốc thời gian: `End: YYYY-MM-DD HH:mm` và tích chọn `[x]`.
---
## 📊 TIẾN ĐỘ THỰC TẾ — TEAM DUY (cập nhật `2026-08-21 10:55`)
> [!NOTE]
> ### ✅ ĐÃ HOÀN TẤT: 16/16 task của **R01, R03, R04** — đã commit & push lên nhánh `feature/deltateam/refactor-plan`
>
> | EPIC | Task | Trạng thái |
> | :--- | :--- | :--- |
> | **R01** Architecture Foundation | T01 → T05 | ✅ 5/5 |
> | **R03** Providers & Routing | T01 → T06 | ✅ 6/6 |
> | **R04** Agent Runtime & Conversation | T01 → T05 | ✅ 5/5 |
>
> **Kiểm chứng (chạy thật, không phải ước lượng):**
> * `pytest tests/` ➔ **243 pass / 2 fail** trong 44s
> * Suite nhanh (`unit + contracts + characterization + routing`) ➔ **218 pass trong 1,16s** (đạt yêu cầu CASAN "A – Automated Tests < 1s cho unit")
> * `python scripts/check_imports.py` ➔ **PASS** (0 Qt import trong `domain/`, `application/`)
> * Mọi file production mới **< 400 dòng** (lớn nhất: `routing_application_service.py` 353 dòng)
> * 2 test fail là **lỗi có sẵn từ trước**, thuộc EPIC **R02**: `config.py` vẫn hardcode `sandbox_pw = "quandh14"` ➔ `tests/test_config_security.py` đỏ
>
> ### 📍 PHẠM VI TEAM DUY & PHẦN CÒN LẠI
> Theo `Feature_Architecture_Proposal.md` (dòng 7) và `DeltaTeam_prompt.md` (dòng 17), Team Duy chủ trì **R01, R03, R04, R08 (phân hệ Chat UI), R10**.
> * ✅ **R01, R03, R04** — xong 16/16 task, đã push.
> * ⬜ **R08 (R08-T01 ➔ R08-T06)** — chưa bắt đầu: tách `ui/chat_panel.py` (1.795 dòng) thành 6 widget < 400 dòng.
> * ⬜ **R10** — làm sau cùng, chờ 3 team hoàn tất.
> * **R02 thuộc 🟣 Team Nam** (xem mục EPIC R02 bên dưới) — đây là nguyên nhân 2 test đỏ ở trên, không phải việc của Team Duy.
>
> ### 📄 BÁO CÁO CHI TIẾT
> Xem `docs/refactor/BaoCao_TeamDuy_R01_R03_R04.md` — kết quả từng EPIC, bằng chứng kiểm thử, 3 lỗi thật đã phát hiện, và phạm vi **chưa** kiểm thử.
>
> ### 📌 CÒN NỢ / CẦN QUYẾT ĐỊNH
> 1. `ProviderRegistry` **chưa nối** vào `state.build_provider_for` (vẫn dùng `providers/factory.py`). Nối vào sẽ sửa luôn lỗi: usage của `ollama`/`github_copilot`/`codex` hiện bị ghi nhận nhầm thành `openai_compat` trên Dashboard — nhưng làm vậy sẽ **đổi cách gom dữ liệu lịch sử**.
> 2. Mode `fallback` đã hỗ trợ ở config + service nhưng **chưa có trên toggle UI** (thuộc R08).
> 3. Đã sửa 2 dòng trong `config.py` (`routing_mode_for` / `set_routing_mode_for`) để dùng chung một bộ từ vựng mode — **cần báo Team Nam** vì file này đang được refactor ở R02.
> 4. Circular import `core/model_pricing.py` ↔ `core/usage_tracker.py` **chưa xử lý** (task ngày 28/08).
> 5. Việc kế tiếp của Team Duy là **R08 phân hệ Chat UI** (6 widget con), rồi **R10** sau cùng.
---
## 📌 PHẦN 1: CHECKLIST CHI TIẾT THEO 10 EPIC (R01 ➔ R10)
### 🔹 EPIC R01: Architecture Foundation & Characterization (Nền Tảng Kiến Trúc & Test Bảo Vệ)
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì ADR & Test Doubles) + Phối hợp cả 3 team
* **Mục tiêu**: Khóa DTO, dựng fakes/test doubles chạy offline không phụ thuộc Qt/mạng, thiết lập script chặn vi phạm kiến trúc.
- [x] **R01-T01 (Team Duy)**: Viết Architecture ADR định rõ ranh giới các tầng ➔ `docs/architecture/ADR-001-layered-architecture.md`
*Start: `2026-08-21 09:56` | End: `2026-08-21 10:00`*
- [x] **R01-T02 (Team Duy)**: Xây dựng `FakeProvider` và `FakeToolExecutor` chạy offline từ `providers/base.py` ➔ `tests/fakes/fake_provider.py` & `tests/fakes/fake_tool_executor.py`
*Start: `2026-08-21 10:00` | End: `2026-08-21 10:02`*
- [x] **R01-T03 (Team Duy)**: Viết script quét tĩnh chặn code mới trong `domain/` và `application/` import `PySide6` ➔ `scripts/check_imports.py`
*Start: `2026-08-21 09:58` | End: `2026-08-21 10:05`*
- [x] **R01-T04 (Team Duy)**: Viết Characterization Tests cho `core/chat_agent.py::run_cowork` ➔ `tests/characterization/test_run_cowork.py`
*Start: `2026-08-21 10:02` | End: `2026-08-21 10:04`*
- [x] **R01-T05 (Team Duy)**: Lập danh mục và phân loại mã nguồn dormant/dead code ➔ `docs/architecture/dormant-code.md`
*Start: `2026-08-21 10:04` | End: `2026-08-21 10:05`*
---
### 🔹 EPIC R02: Configuration, Secrets & Persistence (Cấu Hình Atomic & Bảo Mật Keyring)
* **Team chịu trách nhiệm**: 🟣 **Team Nam** (Chủ trì)
* **Mục tiêu**: Xóa bỏ untyped global `config.py`, cài đặt `AtomicJsonFile` chống hỏng file và lưu trữ API Key/Token vào OS Keyring.
- [ ] **R02-T01 (Team Nam)**: Xây dựng module `AtomicJsonFile` ghi tệp an toàn (tmp file + fsync + atomic replace) ➔ `infrastructure/persistence/json/atomic_json_file.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R02-T02 (Team Nam)**: Refactor `config.py::AppConfig` sử dụng `AtomicJsonFile` ➔ `infrastructure/config/config_repository.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R02-T03 (Team Nam)**: Xây dựng Typed Settings Facade (`ProviderSettings`, `RoutingSettings`) ➔ `infrastructure/config/settings_facade.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R02-T04 (Team Nam)**: Định nghĩa interface `SecretStore` và cài đặt `KeyringAdapter` ➔ `infrastructure/secrets/keyring_adapter.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R02-T05 (Team Nam)**: Di chuyển cấu hình API Key của OpenAI/Anthropic/FPT Gateway sang lưu trữ qua `SecretStore`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R02-T06 (Team Nam)**: Chuẩn hóa JSON schema versioning và recovery policy cho các file data
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
---
### 🔹 EPIC R03: Model Providers & Routing (Hợp Nhất Nhà Cung Cấp & Bộ Định Tuyến Mô Hình)
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì)
* **Mục tiêu**: Hợp nhất logic routing bị phân tán thành `RoutingApplicationService` độc lập Qt; chuẩn hóa catalog nhà cung cấp.
- [x] **R03-T01 (Team Duy)**: Xây dựng bộ Contract Tests chuẩn hóa cho các Provider từ `providers/base.py` ➔ `tests/contracts/test_providers.py`
*Start: `2026-08-21 10:10` | End: `2026-08-21 10:12`*
- [x] **R03-T02 (Team Duy)**: Xây dựng `ProviderDescriptor` và `ProviderRegistry` tập trung từ `providers/factory.py` ➔ `domain/models/provider_descriptor.py` & `infrastructure/providers/provider_registry.py`
*Start: `2026-08-21 10:06` | End: `2026-08-21 10:10`*
- [x] **R03-T03 (Team Duy)**: Xây dựng `RoutingApplicationService` độc lập với Qt từ `core/routing/` ➔ `application/model_routing/routing_application_service.py`
*Start: `2026-08-21 10:12` | End: `2026-08-21 10:15`*
- [x] **R03-T04 (Team Duy)**: Di chuyển luồng gọi routing từ `ui/chat_panel.py#L638` sang `RoutingApplicationService`
*Start: `2026-08-21 10:17` | End: `2026-08-21 10:20`*
- [x] **R03-T05 (Team Duy)**: Di chuyển luồng gọi routing từ `ui/co4e_tab.py` và `ui/folder_tab.py` sang `RoutingApplicationService`
*Start: `2026-08-21 10:20` | End: `2026-08-21 10:22`*
- [x] **R03-T06 (Team Duy)**: Tách logic ghi nhận token usage ra khỏi Provider, chuyển thành `UsageEventSink` ➔ `infrastructure/telemetry/usage_sink.py`
*Start: `2026-08-21 10:15` | End: `2026-08-21 10:17`*
---
### 🔹 EPIC R04: Agent Runtime & Conversation Application Service (Vòng Đời Turn Chat & Agent Engine)
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì)
* **Mục tiêu**: Đóng gói input turn chat thành `ConversationExecutionRequest` bất biến, điều phối vòng đời qua `ConversationApplicationService` và phát sinh sự kiện `AgentEvent` có định kiểu.
- [x] **R04-T01 (Team Duy)**: Định nghĩa immutable dataclass `ConversationExecutionRequest` ➔ `domain/agents/conversation_execution_request.py`
*Start: `2026-08-21 10:23` | End: `2026-08-21 10:25`*
- [x] **R04-T02 (Team Duy)**: Chuẩn hóa các sự kiện `AgentEvent` (TextChunk, ToolCallStarted, ToolCallResult, Error) ➔ `domain/agents/agent_event.py`
*Start: `2026-08-21 10:22` | End: `2026-08-21 10:23`*
- [x] **R04-T03 (Team Duy)**: Xây dựng `ConversationApplicationService` điều phối thực thi từ `core/chat_agent.py` ➔ `application/conversations/conversation_application_service.py`
*Start: `2026-08-21 10:25` | End: `2026-08-21 10:27`*
- [x] **R04-T04 (Team Duy)**: Di chuyển `ui/cowork_tab.py::build_job` sang sử dụng `ConversationExecutionRequest`
*Start: `2026-08-21 10:27` | End: `2026-08-21 10:31`*
- [x] **R04-T05 (Team Duy)**: Di chuyển `core/task_executors.py` sang dùng chung `ConversationApplicationService`
*Start: `2026-08-21 10:28` | End: `2026-08-21 10:30`*
---
### 🔹 EPIC R05: Tool, MCP & Connector Policy (Quản Lý Công Cụ, MCP & Cổng Kiểm Soát Quyền)
* **Team chịu trách nhiệm**: 🟢 **Team Hoa** (Chủ trì) + Phối hợp Team Duy
* **Mục tiêu**: Bóc tách monolithic `core/tools.py`, đưa toàn bộ Built-in tools, MCP tools và Connectors qua `ToolPolicyGateway` kiểm tra quyền phân tầng.
- [ ] **R05-T01 (Team Hoa)**: Định nghĩa `ToolDescriptor`, `ToolCapability` (read/write/execute/network) ➔ `domain/tools/tool_descriptor.py` & `domain/tools/tool_registry.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R05-T02 (Team Hoa)**: Tách nhỏ các built-in handlers từ `core/tools.py` ➔ `infrastructure/filesystem/file_tools.py`, `command_tools.py`, `fetch_tools.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R05-T03 (Team Hoa)**: Xây dựng `ToolPolicyGateway` (kiểm tra phân quyền allow/confirm/deny) ➔ `application/conversations/tool_policy_gateway.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R05-T04 (Team Hoa)**: Chuẩn hóa MCP tools từ `core/mcp_client.py` đi qua `ToolPolicyGateway`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R05-T05 (Team Hoa)**: Xây dựng `McpToolSourceManager` quản lý vòng đời tiến trình MCP ➔ `infrastructure/mcp/mcp_source_manager.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
---
### 🔹 EPIC R06: Workspace, Filesystem & History Isolation (Cô Lập Không Gian Làm Việc & Quản Lý Tệp)
* **Team chịu trách nhiệm**: 🟢 **Team Hoa** (Chủ trì)
* **Mục tiêu**: Xóa bỏ biến toàn cục `state.py::active_project_id`, đóng gói workspace per-turn thành `WorkspaceSession` bất biến, bảo vệ an toàn đường dẫn sandbox.
- [ ] **R06-T01 (Team Hoa)**: Định nghĩa `WorkspaceSession` chứa snapshot project id, workspace root ➔ `domain/workspaces/workspace_session.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R06-T02 (Team Hoa)**: Xây dựng `WorkspaceRepository` từ `core/projects.py` & `ConversationRepository` từ `core/history.py` ➔ `infrastructure/persistence/json/workspace_repository_impl.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R06-T03 (Team Hoa)**: Xây dựng `ExecutionWorkspace` quản lý output/scratch files ➔ `infrastructure/filesystem/execution_workspace.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R06-T04 (Team Hoa)**: Khắc phục race condition trong `ui/workspace_tab.py::_load_current`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R06-T05 (Team Hoa)**: Xây dựng `FileWorkspaceService` xử lý thao tác file cho File Explorer và AI File Editor ➔ `application/workspaces/file_workspace_service.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
---
### 🔹 EPIC R07: Scheduling & Workflow Runtime (Bộ Lập Lịch & Động Cơ Quy Trình)
* **Team chịu trách nhiệm**: 🟢 **Team Hoa** (Task Scheduling) + 🟣 **Team Nam** (Co4E Workflows)
* **Mục tiêu**: Tách `TaskRepository` và `ScheduleCalculator` khỏi `QTimer` trong `core/task_scheduler.py#L20`; xây dựng `TaskApplicationService` và `Co4EWorkflowService`.
- [ ] **R07-T01 (Team Hoa)**: Tách `TaskRepository` lưu trữ JSON độc lập khỏi `core/tasks.py` ➔ `infrastructure/persistence/json/task_repository_impl.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R07-T02 (Team Hoa)**: Xây dựng `ScheduleCalculator` tính due-time / cron độc lập ➔ `domain/tasks/schedule_calculator.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R07-T03 (Team Hoa)**: Xây dựng `QtSchedulerClock` adapter (tách `TaskScheduler` khỏi `QTimer`) ➔ `platform/qt/qt_scheduler_clock.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R07-T04 (Team Hoa)**: Xây dựng `TaskApplicationService` (Pure Python) điều phối chạy, sao chép, dừng, xóa task ➔ `application/scheduling/task_application_service.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R07-T05 (Team Hoa)**: Xây dựng `AiTaskPlannerService` hỗ trợ tạo / import task bằng AI ➔ `application/scheduling/ai_task_planner_service.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R07-T06 (Team Nam)**: Xây dựng `Co4EWorkflowService` (Pure Python) quản lý định nghĩa và thực thi Co4E từ `core/co4e_run_manager.py` ➔ `application/workflows/co4e_workflow_service.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
---
### 🔹 EPIC R08: UI/Application Separation (Phân Rã Toàn Diện Các God Widgets)
* **Team chịu trách nhiệm**: **Cả 3 Team** (Mỗi team phụ trách phân hệ của mình)
* **Mục tiêu**: Phân rã các file giao diện khổng lồ (>1.500 dòng) thành các widget chuyên biệt, mỗi file < 400 dòng code.
#### 🔵 Team Duy (Chat UI Hub):
- [ ] **R08-T01**: Tách `ui/chat_panel.py` thành `presentation/chat/chat_history_widget.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R08-T02**: Tách Composer & input box ➔ `presentation/chat/composer_widget.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R08-T03**: Tách Picker file đính kèm ➔ `presentation/chat/attachment_picker.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R08-T04**: Tách Voice/Audio recording ➔ `presentation/chat/audio_recorder_widget.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R08-T05**: Tách Output panel & file watcher ➔ `presentation/chat/chat_output_panel.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R08-T06**: Lắp ráp container `presentation/chat/chat_panel.py` và tối ưu `Floating HelpAgent`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
#### 🟣 Team Nam (Settings, Monitoring, Co4E & Shell):
- [ ] **R08-T07**: Tách `ui/settings_dialog.py` thành 4 section widgets ➔ `provider_settings_widget.py`, `connector_settings_widget.py`, `routing_settings_widget.py`, `general_settings_widget.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R08-T08**: Tách `ui/monitoring_tab.py` thành 7 tab độc lập (`overview_tab.py`, `sandbox_status_tab.py`, `security_events_tab.py`, `mcp_history_tab.py`, `action_logs_tab.py`, `agent_status_tab.py`, `security_settings_tab.py`)
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R08-T09**: Tách `ui/co4e_tab.py` thành các sub-components ➔ `co4e_canvas_widget.py`, `node_property_panel.py`, `co4e_run_control_widget.py`, `co4e_chat_view.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R08-T10**: Xây dựng `bootstrap.py` (Composition Root) và tách `app.py::MainWindow` (dòng 122) ➔ `presentation/shell/main_window.py`, `tray_manager.py`, `lifecycle_coordinator.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
#### 🟢 Team Hoa (Workspace, Folder, Scheduling, Dashboard & Graph):
- [ ] **R08-T11**: Tách `ui/schedule_task_tab.py` ➔ `kanban_board_widget.py`, `calendar_view_widget.py`, `ai_task_creator_dialog.py`, `ai_task_import_dialog.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R08-T12**: Tách `ui/folder_tab.py#L350` ➔ `workspace_file_tree.py`, `document_preview_manager.py`, `ai_file_editor_dialog.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R08-T13**: Tách `ui/dashboard_tab.py` ➔ `token_usage_card_widget.py`, `usage_chart_widget.py`, `habits_widget.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R08-T14**: Tách `ui/structure_graph_view.py` ➔ `presentation/graph/structure_graph_view.py` & `graph_qa_widget.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
---
### 🔹 EPIC R09: Security Runtime, Sandbox & Observability (An Ninh Runtime, Sandbox & Giám Sát)
* **Team chịu trách nhiệm**: 🟣 **Team Nam** (Chủ trì) + Phối hợp Team Duy
* **Mục tiêu**: Phân biệt deterministic rules và AI guardrails, fix toàn bộ circular imports trong security/pricing, chuẩn hóa schema audit logs.
- [ ] **R09-T01 (Team Nam)**: Viết tài liệu chuẩn hóa Security Policy Model ➔ `docs/architecture/security-policy.md`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R09-T02 (Team Nam)**: Xử lý triệt để Circular Import giữa `core/model_pricing.py` và `core/usage_tracker.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R09-T03 (Team Nam)**: Xử lý triệt để Circular Import giữa `core/agent_security.py` và `core/agent_security_alert.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R09-T04 (Team Nam)**: Xây dựng `CanonicalAuditLogger` thống nhất định dạng log từ `core/audit_log.py` ➔ `infrastructure/telemetry/audit_logger.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R09-T05 (Team Nam)**: Xây dựng `MonitoringQueryService` (truy vấn read-only có phân trang) ➔ `application/monitoring/monitoring_query_service.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R09-T06 (Team Nam)**: Chuẩn hóa ma trận năng lực Sandbox trên từng hệ điều hành từ `core/sandbox_manager.py` ➔ `infrastructure/sandbox/sandbox_capabilities.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
---
### 🔹 EPIC R10: Testing, Packaging & Contributor Experience (Hệ Thống Kiểm Thử & Tài Liệu Đóng Góp)
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì chính - Task trọng tâm của Team Duy)
* **Mục tiêu**: Xây dựng toàn bộ hệ thống test pyramid (unit, contract, integration, headless UI), thiết lập CI Quality Gate tự động, soạn thảo tài liệu Contributor Recipes và thực hiện E2E smoke test trước khi phát hành.
- [ ] **R10-T01 (Team Duy)**: Thiết lập Tháp kiểm thử phân tầng (Unit tests không I/O <0.05s, Contract tests cho Providers/Tools, Integration tests, Fakes library) ➔ `tests/`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R10-T02 (Team Duy)**: Xây dựng Bộ script CI Quality Gate tự động (`scripts/check_imports.py`, `scripts/check_loc.py`, `scripts/audit_security.py`, `scripts/run_quality_gate.py`)
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R10-T03 (Team Duy)**: Cập nhật tài liệu kiến trúc 4 tầng, hướng dẫn setup môi trường & pre-commit hook ➔ `README.md` & `START_CONTRIBUTING.md`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R10-T04 (Team Duy)**: Soạn thảo bộ Contributor Recipes (3 công thức: Thêm Model Provider, Thêm Built-in/MCP Tool, Thêm Màn hình/Widget) ➔ `docs/governance/contributor-recipes.md`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R10-T05 (Team Duy)**: Xây dựng bộ kiểm thử khói phát hành (E2E Release Smoke Test qua headless Qt với 5 kịch bản chính) ➔ `tests/e2e/test_smoke.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
---
## 📅 PHẦN 2: CHECKLIST TIẾN ĐỘ THEO NGÀY CỦA TỪNG TEAM (21/08 ➔ 31/08)
### 🔵 TEAM DUY (Core AI, Routing, Turn Runtime & Testing Lead)
| Ngày | Task Cần Hoàn Thành | Start Time | End Time | Trạng Thái |
| :--- | :--- | :---: | :---: | :---: |
| **21/08 (T6)** | Khóa DTO `ConversationExecutionRequest`, `AgentEvent`; Xây dựng `FakeProvider`, `FakeToolExecutor` | `2026-08-21 09:56` | `2026-08-21 10:25` | [x] |
| **22-23/08 (T7-CN)** | Chuẩn hóa `ProviderDescriptor`, `ProviderRegistry`; Wrap OpenAI, Anthropic, Ollama, FPT Gateway; Viết Contract Tests | `2026-08-21 10:06` | `2026-08-21 10:12` | [x] ⚠️ registry chưa nối vào `state.build_provider_for` |
| **24/08 (T2)** | Xây dựng `RoutingApplicationService` độc lập Qt; Tách `ComposerWidget` & `AttachmentPicker` | `2026-08-21 10:12` | `2026-08-21 10:15` | [~] RoutingApplicationService xong; tách widget thuộc R08 |
| **25/08 (T3)** | Xây dựng `ConversationApplicationService`; Tách `ChatHistoryWidget` và bubble renderer | `2026-08-21 10:25` | `2026-08-21 10:27` | [~] Service xong; tách widget thuộc R08 |
| **26/08 (T4)** | Nối stream `AgentEvent` sang Chat History; Tách `AudioRecorderWidget` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **27/08 (T5)** | Tách `ChatOutputPanel` & File Watcher; Lắp ráp container `ChatPanel` và `Floating HelpAgent` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **28/08 (T6)** | Xóa copy routing cũ trong `ui/chat_panel.py`; Fix circular import `model_pricing` ↔ `usage_tracker` | `2026-08-21 10:17` | `2026-08-21 10:22` | [~] 3 bản copy routing đã gỡ; circular import chưa xử lý |
| **29/08 (T7)** | Viết suite integration test cho toàn bộ luồng Chat (`tests/integration/test_chat_flow.py`) | `2026-08-21 10:35` | `2026-08-21 10:52` | [~] 25 integration test tại `tests/integration/{test_cowork_turn_flow,test_task_executor_flow,test_routing_surfaces}.py` |
| **30/08 (CN)** | 🔍 **Chủ trì CASAN Check 3**: Chạy `python scripts/check_imports.py` đảm bảo 0 import `PySide6` trong domain & application | `2026-08-21 09:58` | `2026-08-21 10:05` | [x] PASS |
| **31/08 (T2)** | **Chủ trì EPIC R10**: Viết Contributor Recipes, chạy E2E Smoke Test (`tests/e2e/test_smoke.py`) và merge PR cuối cùng | `____-__-__ __:__` | `____-__-__ __:__` | [ ] chờ 3 team hoàn tất |
---
### 🟣 TEAM NAM (Automation Workflows, Co4E, Monitoring & Governance)
| Ngày | Task Cần Hoàn Thành | Start Time | End Time | Trạng Thái |
| :--- | :--- | :---: | :---: | :---: |
| **21/08 (T6)** | Khóa DTO Co4E; Xây dựng `AtomicJsonFile` và `KeyringAdapter` (`SecretStore`) | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **22-23/08 (T7-CN)** | Refactor `config.py` sang `ConfigRepository`; Tách `ProviderSettingsWidget` & `ConnectorSettingsWidget` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **24/08 (T2)** | Tách 3 tab đầu của Monitoring (`overview_tab.py`, `sandbox_status_tab.py`); Xây dựng `MonitoringQueryService` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **25/08 (T3)** | Tách 4 tab còn lại của Monitoring (`security_events_tab.py`, `mcp_history_tab.py`,...); Lắp ráp container `MonitoringTab` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **26/08 (T4)** | Bóc tách `Co4EWorkflowService`; Tách `NodePropertyPanel` & `AgentListPanel` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **27/08 (T5)** | Tách `Co4ECanvasWidget`, `Co4ERunControlWidget` & `Co4EChatView` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **28/08 (T6)** | Lắp ráp container `Co4ETab`; Xây dựng `bootstrap.py` (Composition Root) và tách `MainWindow` shell | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **29/08 (T7)** | Fix circular import `agent_security` ↔ `agent_security_alert`; Integration test luồng Co4E & Settings | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **30/08 (CN)** | 🔍 **Chủ trì CASAN Check 1**: Chạy `python scripts/audit_security.py` đảm bảo 0 API Key/Token plaintext | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **31/08 (T2)** | Fix tồn đọng Check 1, cập nhật tài liệu kiến trúc, merge PR cuối | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
---
### 🟢 TEAM HOA (Workspace, Filesystem, Scheduling & Tool Registry)
| Ngày | Task Cần Hoàn Thành | Start Time | End Time | Trạng Thái |
| :--- | :--- | :---: | :---: | :---: |
| **21/08 (T6)** | Khóa DTO `ToolDescriptor`, `ToolCapability`; Tách `FileTools` từ `core/tools.py` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **22-23/08 (T7-CN)** | Tách `CommandTools`, `FetchTools`; Tách `TokenUsageCardWidget` & `UsageChartWidget` (Dashboard) | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **24/08 (T2)** | Tách `TaskRepository` & `ScheduleCalculator`; Tách `KanbanBoardWidget` (7 cột trạng thái) | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **25/08 (T3)** | Xây dựng `QtSchedulerClock` adapter (tách khỏi `QTimer`); Tách `CalendarViewWidget` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **26/08 (T4)** | Xây dựng `TaskApplicationService`; Tách `AiTaskCreatorDialog` & `AiTaskImportDialog` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **27/08 (T5)** | Tách `WorkspaceFileTree`, `DocumentPreviewManager` & `AiFileEditorDialog` từ `FolderTab` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **28/08 (T6)** | Tách `StructureGraphView` (GraphRAG); Lắp ráp shell `FolderTab` & `ScheduleTaskTab` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **29/08 (T7)** | Nối `ToolPolicyGateway` qua MCP Client & Built-in Tools; Integration test Task Scheduler & File Explorer | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **30/08 (CN)** | 🔍 **Chủ trì CASAN Check 2**: Chạy `python scripts/check_loc.py --max-lines 400` đảm bảo 0 file >400 dòng | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **31/08 (T2)** | Fix tồn đọng Check 2, cập nhật README, merge PR cuối | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
---
## 🚦 PHẦN 3: CHECKLIST CHECKPOINT REVIEW & CƠ CHẾ KIỂM DUYỆT CASAN
### 🛡️ Định nghĩa 5 Chữ Cái CASAN:
- **C - Clean Architecture**: 0 import `PySide6` trong `domain/` và `application/`.
- **A - Atomic Persistence**: 0 plaintext secrets trong JSON/config; dùng `AtomicJsonFile` ghi tệp an toàn.
- **S - Single Responsibility**: 0 file production nào > 400 dòng code (LOC).
- **A - Automated Test Pyramid**: Bộ test phân tầng chạy offline 100% không phụ thuộc network/UI.
- **N - No Regression & Smoke**: Toàn bộ suite test (>81 tests) và E2E Smoke test pass 100%.
### 🔍 Bảng Theo Dõi Các Checkpoints & Cổng Kiểm Duyệt CASAN:
| Thời Điểm | Checkpoint / Cổng Duyệt | Lệnh Kiểm Tra Thực Tế | Tiêu Chí Bắt Buộc | Phụ Trách | Start Time | End Time | Trạng Thái |
| :--- | :--- | :--- | :--- | :--- | :---: | :---: | :---: |
| **23/08 (CN - 17:00)** | **Checkpoint 1: Contracts & Fakes** | `pytest tests/contracts tests/fakes` | 100% DTO và Fake Services tạo xong; test pass | Cả 3 Team | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **28/08 (T6 - 17:00)** | **Checkpoint 2: Services & Sub-widgets** | `pytest tests/` | Tách xong 100% God Files; 0 circular import | Cả 3 Team | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **30/08 (CN - 17:00)** | **CASAN Check 1: Security Audit** | `python scripts/audit_security.py` | 0 plaintext secret trong file cấu hình | Team Nam | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **30/08 (CN - 17:00)** | **CASAN Check 2: Modularity (LOC)** | `python scripts/check_loc.py --max-lines 400` | 0 file production nào > 400 dòng code | Team Hoa | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **30/08 (CN - 17:00)** | **CASAN Check 3: Clean Architecture** | `python scripts/check_imports.py` | 0 import `PySide6` trong domain & application | Team Duy | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **31/08 (T2 - 15:00)** | **Final Release E2E Smoke Test** | `pytest tests/e2e/test_smoke.py` | 5 kịch bản end-to-end pass 100% trên `main` | Team Duy & 3 Team | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
---
## 📋 PHẦN 4: DEFINITION OF DONE (DOD) CHO MỖI PULL REQUEST
Mọi Pull Request của cả 3 team trước khi merge vào nhánh chính cần được đối chiếu checklist sau:
- [ ] **1. Kích thước file (LOC)**: File mới hoặc file sau khi refactor không vượt quá **400 dòng code**.
- [ ] **2. Phụ thuộc kiến trúc (Clean Architecture)**: Không import `PySide6` / Qt trong các module thuộc `domain/` và `application/`.
- [ ] **3. An toàn thông tin (Security)**: API Key / Credential được lưu trữ qua `SecretStore` (Keyring), không lưu cứng hoặc lưu plaintext trong file JSON.
- [ ] **4. Bắt buộc Comment Code bằng Tiếng Anh (English In-Code Comments)**: 100% các dòng hoặc khối code sửa đổi/bóc tách đều có comment tiếng Anh giải thích rõ logic xử lý và lý do kỹ thuật.
- [ ] **5. Ghi nhận thời gian thực hiện (Timestamps)**: Đã điền đầy đủ mốc thời gian `Start: YYYY-MM-DD HH:mm` và `End: YYYY-MM-DD HH:mm` vào `Refactoring_Checklist.md` và PR description.
- [ ] **6. Kiểm thử tự động (Automated Tests)**: Có unit test hoặc contract test đi kèm với tỷ lệ pass 100%. Chạy `pytest` hoàn tất < 3 giây.
- [ ] **7. Không gây lỗi chéo (No Regression)**: Chạy kiểm thử toàn bộ hệ thống không làm hỏng các tính năng hiện hữu.
+682
View File
@@ -0,0 +1,682 @@
## 🗓️ VI. LỘ TRÌNH THỰC HIỆN - 10 EPIC (REFACTORING ROADMAP)
### Bảng Tổng Quan 10 EPIC
| EPIC | Tên | Dependency | Giá Trị Kiến Trúc |
| :--- | :--- | :--- | :--- |
| **R01** | Architecture Foundation & Characterization | Không | Safety net + ngôn ngữ chung trước khi nhiều người sửa |
| **R02** | Configuration, Secrets & Persistence | R01 | Loại bỏ global dict/direct write và bảo vệ credential |
| **R03** | Model Providers & Routing | R01, R02 | 1 đường mở rộng provider, 1 routing flow duy nhất |
| **R04** | Agent Runtime & Conversation Service | R01, R03 | Tách turn lifecycle khỏi widget |
| **R05** | Tool, MCP & Connector Policy | R01, R04 | 1 security/approval path cho mọi tool call |
| **R06** | Workspace, Filesystem & History Isolation | R01, R02 | Loại bỏ cross-project mutable path/state |
| **R07** | Scheduling & Workflow Runtime | R01, R04, R06 | Tách Qt timer, persistence và runtime dispatch |
| **R08** | UI/Application Separation | R03 - R07 | Thu nhỏ God widgets theo từng screen |
| **R09** | Security Runtime, Sandbox & Observability | R01, R05 | Policy rõ, event schema thống nhất |
| **R10** | Testing, Packaging & Contributor Experience | Tất cả | CI, docs, contributor có thể sửa 1 capability độc lập |
---
### 💡 Chiến Lược Triển Khai Song Song 100% Cho 3 Team (Zero Blocking)
Để 3 team làm việc cùng lúc từ **21/08 đến 31/08/2026** mà không bị nghẽn (blocked), không phải chờ đợi nhau và loại bỏ hoàn toàn rủi ro merge conflict:
1. **Ranh giới sở hữu mã nguồn tuyệt đối (Code Ownership & Zero File Overlap)**: Mỗi file/thư mục chỉ thuộc quyền chỉnh sửa của duy nhất 1 team. Tuyệt đối không để 2 team cùng sửa chung 1 file cùng lúc.
2. **Nguyên tắc Contract-First & Mock-Driven**: Thống nhất Data Contract / DTO / Interface ngay từ Ngày 1. Khi cần gọi chéo giữa các phân hệ, team gọi sẽ dùng `Fake/Mock Adapter` để hoàn thiện UI/logic nội bộ mà **không cần chờ** team kia hoàn thành implementation.
3. **Phân chia theo Phân hệ nghiệp vụ (Vertical Domain Slices)**: Mỗi team phụ trách trọn vẹn từ UI Sub-widgets đến Application Service và Infrastructure của phân hệ đó, đảm bảo tính tự chủ và khả năng test độc lập.
```mermaid
graph TD
subgraph T1 [🔵 TEAM 1: Core AI & Conversation Hub]
UI1[presentation/chat/] --> APP1[application/conversations/<br>application/model_routing/]
APP1 --> DOM1[domain/agents/<br>domain/models/]
APP1 --> INF1[infrastructure/providers/]
end
subgraph T2 [🟣 TEAM 2: Automation, Workflows & Governance]
UI2[presentation/co4e/<br>presentation/monitoring/<br>presentation/settings/] --> APP2[application/workflows/<br>application/monitoring/<br>application/settings/]
APP2 --> DOM2[domain/workflows/<br>domain/security/]
APP2 --> INF2[infrastructure/config/<br>infrastructure/secrets/]
end
subgraph T3 [🟢 TEAM 3: Workspace, Tools & Scheduling]
UI3[presentation/folder/<br>presentation/scheduling/<br>presentation/dashboard/<br>presentation/graph/] --> APP3[application/workspaces/<br>application/scheduling/]
APP3 --> DOM3[domain/tools/<br>domain/tasks/]
APP3 --> INF3[infrastructure/filesystem/<br>infrastructure/mcp/<br>infrastructure/persistence/]
end
style T1 fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
style T2 fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
style T3 fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
```
---
### 🖥️ Cấu Trúc Giao Diện Thực Tế & Bản Đồ Điều Hướng (Verified UI Layout & Navigation Map)
Qua kiểm tra trực tiếp mã nguồn thực tế của giao diện (`app.py`, `ui/workspace_tab.py`, `ui/chat_panel.py`, `ui/co4e_tab.py`, `ui/folder_tab.py`, `ui/monitoring_tab.py`), cấu trúc layout hiện tại của Cowork Local được thiết kế theo mô hình **Thanh điều hướng phẳng (Flat Collapsible Nav Rail) + Không gian làm việc đa phân hệ (Workspace Hub)**:
```mermaid
graph TD
MW["MainWindow (app.py)"]
subgraph NR ["👈 Collapsible Left Nav Rail (54px / 150px)"]
N_TOP["Header: Project Picker + '+ Chat Mới'"]
N_MAIN["Main Nav (Flat List)"]
N_REC["Section: GẦN ĐÂY (Recent Threads)"]
N_BOT["Bottom Nav (Ghim Đáy)"]
N_FOOT["Footer: Cài Đặt (Settings) + Tài Khoản"]
end
subgraph CA ["👉 Main Content Area (QStackedWidget)"]
P_WS["📁 WorkspaceTab (Trang Chủ Chính)"]
P_SCH["⏰ ScheduleTaskTab (Lịch Trình)"]
P_DB["📊 DashboardTab (Bảng Điều Khiển)"]
P_MON["🛡️ MonitoringTab (Giám Sát & Quản Trị - 8 Tabs)"]
end
subgraph WST ["📦 Các Sub-Tabs Trong Workspace (Điều khiển từ Nav Rail)"]
ST_PROJ["1. 📁 Dự Án (Project info, instructions, folder path)"]
ST_COW["2. 💬 Cowork (Chat Panel + Outer History Sidebar)"]
ST_CO4E["3. ⚡ Co4E Studio (Canvas Node, Agent/Skill Palette, Run Chat)"]
ST_FOLD["4. 📂 Folder Explorer (Tree, Code Editor, Preview, Terminal, AI Edit)"]
ST_GRAPH["5. 🕸️ GraphRAG (Knowledge Graph View + Q&A Panel)"]
end
MW --> NR
MW --> CA
N_MAIN -->|Chuyển sub-tab| WST
N_MAIN -->|Mở trang| P_SCH
N_BOT -->|Mở trang| P_DB
N_BOT -->|Mở trang| P_MON
P_WS --> WST
style MW fill:#1e293b,stroke:#0ea5e9,color:#fff
style NR fill:#0f172a,stroke:#334155,color:#fff
style CA fill:#1e293b,stroke:#475569,color:#fff
style WST fill:#334155,stroke:#38bdf8,color:#fff
```
#### 📌 Chi Tiết Thành Phần Giao Diện Của Từng Phân Hệ:
1. **Thanh Điều Hướng Trái (Left Nav Rail - `app.py`):**
- Nút thu gọn / mở rộng (Menu toggle 54px ↔ 150px).
- Bộ chọn nhanh dự án (`nav_project` / `nav_project_btn`) & Nút `+ Chat mới` (`nav_new_chat`).
- Danh sách phẳng các màn hình làm việc chính (Dự án, Cowork, Co4E, Folder, GraphRAG, Lịch trình).
- Danh sách hội thoại gần đây (`RECENTS`) của dự án đang chọn.
- Nhóm ghim đáy (Bảng điều khiển, Giám sát) + Nút mở Cài đặt & Hàng thông tin tài khoản.
- **Trợ lý nổi (Floating Help Agent - `ui/help_agent_widget.py`):** Biểu tượng robot ghim góc dưới phải ở mọi màn hình, click là mở cửa sổ chat trợ giúp nhanh.
2. **Workspace Tab (Trang Chủ - `ui/workspace_tab.py`):**
- **Cột trái:** Danh sách quản lý Dự án (Create, Delete, đổi tên, thu gọn / mở rộng).
- **Cột giữa:** Thanh lịch sử hội thoại ngoài (`ui/sidebar.py::HistorySidebar`) hiển thị xuyên suốt cho cả Cowork và GraphRAG.
- **Vùng chính:** Chứa 5 sub-tabs (ẩn thanh tab bar ngang để Nav Rail điều hướng trực tiếp):
- **Dự Án (`ProjectTab`):** Tên, mô tả, chỉ dẫn chung (shared instructions), đường dẫn thư mục sandbox, danh sách luồng chat.
- **Cowork (`ui/cowork_tab.py`):** Khung chat chính (`ui/chat_panel.py`, `ui/chat_view.py`, `ui/composer.py`).
- **Co4E Studio (`ui/co4e_tab.py`):** Canvas thiết kế luồng đồ thị node (`ui/co4e_canvas.py`), bảng chỉnh thuộc tính node (`ui/co4e_config_panel.py`), thư viện Agent/Skill, bộ điều khiển chạy luồng & Chat view tương tác.
- **Folder Explorer (`ui/folder_tab.py`):** Cây thư mục workspace, trình soạn thảo code syntax highlight, trình xem trước tài liệu đa định dạng (PDF, MS Office qua LibreOffice `ui/libreoffice_view.py`, HTML, Ảnh), Terminal tích hợp (`ui/terminal_panel.py`), và Dialog sửa code bằng AI (`ui/file_edit_dialog.py`).
- **GraphRAG (`ui/structure_graph_view.py`):** Đồ thị tri thức D3 WebEngine / Native 2D, bộ lọc thực thể, panel hỏi đáp ngữ cảnh mã nguồn (Graph Q&A).
3. **Schedule Task Tab (Lịch Trình - `ui/schedule_task_tab.py`):**
- Bảng Kanban 7 cột trạng thái (Backlog, Todo, In Progress, Review, Done, Blocked, Cancelled) hỗ trợ kéo thả.
- Chế độ xem Lịch tháng (`ui/calendar_view.py`) trực quan hóa các task định kỳ và due dates.
- Dialog chỉnh sửa task (`ui/task_editor_dialog.py`) & các bộ tạo task tự động bằng AI.
4. **Dashboard Tab (Bảng Điều Khiển - `ui/dashboard_tab.py`):**
- Thẻ thống kê tổng lượng Token tiêu thụ, chi phí ước tính, số lượng tác vụ đã chạy.
- Biểu đồ Spline trực quan hóa xu hướng chi phí theo thời gian (`ui/spline_chart.py`).
- Bảng thói quen sử dụng mô hình (AI Model Habits) và hạn mức ngân sách.
5. **Monitoring Tab (Giám Sát & Quản Trị - `ui/monitoring_tab.py`):**
- Giữ nguyên tab bar nội bộ với 8 tab chuyên trách:
1. **Tổng quan (Overview):** Metrics CPU, Memory, số tiến trình sandbox, tổng log.
2. **Trạng thái Sandbox (Sandbox Status):** Giám sát các container/sub-process cách ly.
3. **Sự kiện bảo mật (Security Events):** Danh sách cảnh báo vi phạm policy an toàn.
4. **Lịch sử MCP (MCP History):** Nhật ký gọi tool MCP và latency.
5. **Nhật ký hoạt động (Action Logs):** Log chi tiết mọi thao tác đọc/ghi tệp, thực thi lệnh.
6. **Quản trị Agent (`ui/agents_admin_tab.py`):** Cấu hình Prompt và tham số cho các Agent chuyên biệt & Help Agent.
7. **Cài đặt bảo mật (Security Settings):** Bật/tắt các rào chắn Sandbox và phê duyệt công cụ.
8. **Quản trị Tool / Icon (`ui/tools_admin_tab.py`, `ui/icons_admin_tab.py`):** Quản lý metadata công cụ và bộ icon hệ thống.
6. **Hộp Thoại Cài Đặt (Settings Dialog - `ui/settings_dialog.py`):**
- Cài đặt Nhà cung cấp (OpenAI, Anthropic, Ollama, FPT Gateway).
- Cài đặt Connectors (MCP Server, MS365, External APIs).
- Cài đặt Định tuyến mô hình (Off, Auto, Manual, Fallback rules).
- Cài đặt Chung (Ngôn ngữ, Giao diện Theme, Khởi động cùng hệ thống, System Tray).
---
### 👥 Ranh Giới Phân Hệ & Phạm Vi Của 3 Team (Duy, Nam, Hoa)
| Team | Phân Hệ Phụ Trách | Phạm Vi Thư Mục Sở Hữu | File Cũ Cần Phân Rã / Tái Cấu Trúc | Trọng Tâm EPIC |
| :--- | :--- | :--- | :--- | :--- |
| **🔵 TEAM DUY**<br>*(Tech Lead)* | **Core AI, Routing, Agent Engine & Testing Lead** | `presentation/chat/`<br>`application/conversations/`<br>`application/model_routing/`<br>`domain/agents/`, `domain/models/`<br>`infrastructure/providers/`<br>`tests/` (Unit, Contract, Integration, E2E) | `ui/chat_panel.py`<br>`ui/cowork_tab.py`<br>`ui/help_agent_widget.py`<br>`core/chat_agent.py`<br>`core/routing/*`<br>`providers/*` | **R01, R03, R04, R10**<br>(Routing, Providers, Agent Engine, Chat UI, Floating Help Agent, Testing Pyramid, Contributor Recipes) |
| **🟣 TEAM NAM** | **Automation, Workflows, Governance & Security** | `presentation/co4e/`<br>`presentation/monitoring/`<br>`presentation/settings/`<br>`presentation/shell/`, `bootstrap.py`<br>`application/workflows/`, `monitoring/`, `settings/`<br>`infrastructure/config/`, `secrets/`, `sandbox/` | `ui/co4e_tab.py`<br>`ui/monitoring_tab.py`<br>`ui/settings_dialog.py`<br>`app.py::MainWindow`<br>`config.py`<br>`core/co4e_run_manager.py` | **R02, R08, R09**<br>(Co4E Studio, Monitoring 8 tabs, Settings, Keyring, Nav Rail & Shell, Security Scan) |
| **🟢 TEAM HOA** | **Workspace, Filesystem, Tools & Scheduling** | `presentation/workspace/`<br>`presentation/folder/`<br>`presentation/scheduling/`<br>`presentation/dashboard/`<br>`presentation/graph/`<br>`application/workspaces/`, `scheduling/`<br>`domain/tools/`, `domain/tasks/`<br>`infrastructure/filesystem/`, `mcp/`, `persistence/` | `ui/workspace_tab.py`<br>`ui/sidebar.py`<br>`ui/folder_tab.py`<br>`ui/structure_graph_view.py`<br>`ui/schedule_task_tab.py`<br>`ui/dashboard_tab.py`<br>`core/tools.py`<br>`core/task_executors.py`<br>`core/task_scheduler.py` | **R05, R06, R07, R08**<br>(Tools, Tasks, Workspace Project Manager, Folder Explorer & Editor, Graph RAG, Kanban Schedule, Dashboard) |
---
### 📅 Lịch Tổng Quan Theo Tuần (21/08 - 31/08/2026)
```mermaid
gantt
title Lộ Trình Phân Chia 3 Team Song Song (21/08 - 31/08/2026)
dateFormat YYYY-MM-DD
section Team Duy (Core AI, Chat & Testing Lead)
Khóa DTO + FakeProvider + Provider Registry :t1_1, 2026-08-21, 3d
RoutingService + Tách Composer & ChatHistory :t1_2, 2026-08-24, 3d
ConversationService + ChatOutput + ChatPanel Shell :t1_3, 2026-08-27, 3d
CASAN Check 3 + EPIC R10 Testing Pyramid & Smoke :t1_4, 2026-08-30, 2d
section Team Nam (Workflow & Governance)
Khóa DTO + AtomicConfig + Keyring + Settings Split :t2_1, 2026-08-21, 3d
MonitoringTab Split (7 tabs) + MonitoringService :t2_2, 2026-08-24, 2d
Co4E Canvas + RunControl + WorkflowService :t2_3, 2026-08-26, 3d
Bootstrap Root + CASAN Check 1 (Security Scan) :t2_4, 2026-08-29, 3d
section Team Hoa (Workspace, Tools & Scheduling)
Khóa DTO + ToolRegistry + File Tools + Dashboard :t3_1, 2026-08-21, 3d
TaskRepo + Clock + Kanban + Calendar View :t3_2, 2026-08-24, 3d
FolderTree + DocumentPreview + Graph RAG :t3_3, 2026-08-27, 3d
CASAN Check 2 (Single Responsibility) + E2E Support :t3_4, 2026-08-30, 2d
```
---
### 🗓️ KẾ HOẠCH CHI TIẾT TỪNG NGÀY CHO 3 TEAM (21/08 ➔ 31/08)
#### 🔵 TEAM DUY: Core AI, Routing & Testing Lead (Tech Lead)
| Ngày | Công Việc Cụ Thể & Nơi Bóc Tách Code | File Đích Cần Tạo / Chỉnh Sửa | Tiêu Chí Kiểm Thử (Validation) |
| :--- | :--- | :--- | :--- |
| **21/08 (T6)** | • Khóa DTO từ `core/chat_agent.py`<br>• Xây dựng test doubles từ `providers/base.py` | ➔ `domain/agents/conversation_execution_request.py`<br>➔ `domain/agents/agent_event.py`<br>➔ `tests/fakes/fake_provider.py` | Unit test chạy <1s, không phụ thuộc Qt hay network |
| **22-23/08 (T7-CN)** | • Chuẩn hóa catalog từ `providers/factory.py`<br>• Wrap OpenAI, Anthropic, Ollama, FPT Gateway | ➔ `domain/models/provider_descriptor.py`<br>➔ `infrastructure/providers/provider_registry.py` | Golden response test cho từng provider |
| **24/08 (T2)** | • Hợp nhất routing từ `ui/chat_panel.py#L638` & `core/routing/`<br>• Tách Composer & Picker từ `ui/composer.py` | ➔ `application/model_routing/routing_application_service.py`<br>➔ `presentation/chat/composer_widget.py`<br>➔ `presentation/chat/attachment_picker.py` | Test routing policy không cần Qt; Composer test |
| **25/08 (T3)** | • Tách turn orchestration từ `ui/chat_panel.py#L70`<br>• Tách chat bubble/markdown từ `ui/chat_view.py` | ➔ `application/conversations/conversation_application_service.py`<br>➔ `presentation/chat/chat_history_widget.py` | Turn test với `FakeProvider`: text stream & tool calls |
| **26/08 (T4)** | • Nối sự kiện `AgentEvent` sang History Widget<br>• Tách ghi âm audio từ `ui/chat_panel.py` | ➔ `presentation/chat/audio_recorder_widget.py` | Event streaming UI test không lag main thread |
| **27/08 (T5)** | • Tách file watcher & output panel từ `ui/chat_panel.py#L18`<br>• Lắp ráp shell hoàn chỉnh | ➔ `presentation/chat/chat_output_panel.py`<br>➔ `presentation/chat/chat_panel.py` | Smoke test: ChatPanel mở mượt mà, render đủ thành phần |
| **28/08 (T6)** | • Xóa routing copy trong `ui/chat_panel.py`<br>• Fix circular import `core/model_pricing.py` ↔ `core/usage_tracker.py` | ➔ Patch các module liên quan | `python -c "import cowork_local"` không phát sinh lỗi |
| **29/08 (T7)** | • Viết suite integration test cho toàn bộ luồng Chat<br>• Rà soát số dòng code Team Duy (<400 dòng/file) | ➔ `tests/integration/test_chat_flow.py` | 100% test pass |
| **30/08 (CN)** | 🔍 **Chủ trì CASAN Check 3 (Import Guard)**: Quét tĩnh kiểm tra `domain/` & `application/` không import `PySide6` | ➔ `scripts/check_imports.py` | 0 violation trong code mới |
| **31/08 (T2)** | 🎯 **Chủ trì EPIC R10 (Task Chính Team Duy)**: Thiết lập Testing Pyramid, Contributor Recipes, E2E Smoke Test & Merge PR cuối | ➔ `tests/e2e/test_smoke.py`<br>➔ `docs/governance/contributor-recipes.md` | All tests pass, CASAN Gate PASS |
---
#### 🟣 TEAM NAM: Automation, Workflows, Governance & Security
| Ngày | Công Việc Cụ Thể & Nơi Bóc Tách Code | File Đích Cần Tạo / Chỉnh Sửa | Tiêu Chí Kiểm Thử (Validation) |
| :--- | :--- | :--- | :--- |
| **21/08 (T6)** | • Khóa DTO từ `core/co4e.py`<br>• Xây dựng Atomic Write & Keyring từ `config.py` | ➔ `infrastructure/persistence/json/atomic_json_file.py`<br>➔ `infrastructure/secrets/keyring_adapter.py` | Fault-injection test (atomic write); Credential store test |
| **22-23/08 (T7-CN)** | • Chuyển đổi `config.py` sang `ConfigRepository`<br>• Tách section từ `ui/settings_dialog.py` | ➔ `infrastructure/config/config_repository.py`<br>➔ `presentation/settings/provider_settings_widget.py`<br>➔ `presentation/settings/connector_settings_widget.py` | Config round-trip test; Settings UI render test |
| **24/08 (T2)** | • Tách 3 tab đầu từ `ui/monitoring_tab.py`<br>• Xây dựng truy vấn dữ liệu độc lập | ➔ `presentation/monitoring/overview_tab.py`<br>➔ `presentation/monitoring/sandbox_status_tab.py`<br>➔ `application/monitoring/monitoring_query_service.py` | Render dữ liệu thống kê độc lập |
| **25/08 (T3)** | • Tách 4 tab còn lại từ `ui/monitoring_tab.py`<br>• Lắp ráp shell Monitoring | ➔ `presentation/monitoring/security_events_tab.py`<br>➔ `presentation/monitoring/mcp_history_tab.py`<br>➔ `presentation/monitoring/monitoring_tab.py` | Smoke test: MonitoringTab chuyển tab mượt, filter log tốt |
| **26/08 (T4)** | • Bóc tách runner từ `core/co4e_run_manager.py`<br>• Tách config & agent panels từ `ui/co4e_tab.py#L3` | ➔ `application/workflows/co4e_workflow_service.py`<br>➔ `presentation/co4e/node_property_panel.py`<br>➔ `presentation/co4e/agent_list_panel.py` | Workflow CRUD & validation test độc lập |
| **27/08 (T5)** | • Tách Canvas vẽ node từ `ui/co4e_canvas.py`<br>• Tách Run control & chat view từ `ui/co4e_tab.py#L228` | ➔ `presentation/co4e/co4e_canvas_widget.py`<br>➔ `presentation/co4e/co4e_run_control_widget.py`<br>➔ `presentation/co4e/co4e_chat_view.py` | Canvas node operations test |
| **28/08 (T6)** | • Lắp ráp container Co4ETab<br>• Tách Composition root & MainWindow từ `app.py#L122` | ➔ `presentation/co4e/co4e_tab.py`<br>➔ `bootstrap.py`<br>➔ `presentation/shell/main_window.py` | Khởi động app qua `bootstrap.py` thành công |
| **29/08 (T7)** | • Fix circular import `core/agent_security.py` ↔ `core/agent_security_alert.py`<br>• Integration test luồng Co4E & Settings | ➔ Patch security modules | Co4E flow chạy trơn tru |
| **30/08 (CN)** | 🔍 **Chủ trì CASAN Check 1 (Security Scan)**: Quét rà soát toàn bộ file config/JSON để đảm bảo 0 API Key/Token lưu plaintext | ➔ Script security audit | 0 credential plaintext |
| **31/08 (T2)** | Fix tồn đọng Check 1, cập nhật tài liệu kiến trúc, merge PR cuối | — | CASAN Check 1 PASS |
---
#### 🟢 TEAM HOA: Workspace, Filesystem, Tools & Scheduling
| Ngày | Công Việc Cụ Thể & Nơi Bóc Tách Code | File Đích Cần Tạo / Chỉnh Sửa | Tiêu Chí Kiểm Thử (Validation) |
| :--- | :--- | :--- | :--- |
| **21/08 (T6)** | • Khóa DTO từ `core/tools.py` & `core/tasks.py`<br>• Tách file tools từ `core/tools.py` | ➔ `domain/tools/tool_descriptor.py`<br>➔ `domain/tools/tool_registry.py`<br>➔ `infrastructure/filesystem/file_tools.py` | Tool handler test độc lập; Atomic write test |
| **22-23/08 (T7-CN)** | • Tách command, fetch, image tools từ `core/tools.py`<br>• Tách card & chart từ `ui/dashboard_tab.py` | ➔ `infrastructure/filesystem/command_tools.py`<br>➔ `infrastructure/filesystem/fetch_tools.py`<br>➔ `presentation/dashboard/token_usage_card_widget.py`<br>➔ `presentation/dashboard/usage_chart_widget.py` | Tool execution test; Dashboard chart test với mock data |
| **24/08 (T2)** | • Tách repository & do lịch từ `core/tasks.py`<br>• Tách Kanban board từ `ui/schedule_task_tab.py` | ➔ `infrastructure/persistence/json/task_repository_impl.py`<br>➔ `domain/tasks/schedule_calculator.py`<br>➔ `presentation/scheduling/kanban_board_widget.py` | Task CRUD test; Kanban card render test |
| **25/08 (T3)** | • Tách `QTimer` adapter từ `core/task_scheduler.py#L20`<br>• Tách Calendar view từ `ui/schedule_task_tab.py` | ➔ `platform/qt/qt_scheduler_clock.py`<br>➔ `presentation/scheduling/calendar_view_widget.py` | Fake clock test kích hoạt task đúng lịch |
| **26/08 (T4)** | • Tách dispatch logic từ `core/task_executors.py`<br>• Tách AI create dialogs từ `ui/schedule_task_tab.py` | ➔ `application/scheduling/task_application_service.py`<br>➔ `presentation/scheduling/ai_task_creator_dialog.py` | Task dispatch test; AI planner test với fake provider |
| **27/08 (T5)** | • Tách File tree & Previews từ `ui/folder_tab.py#L350`<br>• Tách AI File Editor từ `ui/folder_tab.py` | ➔ `presentation/folder/workspace_file_tree.py`<br>➔ `presentation/folder/document_preview_manager.py`<br>➔ `application/workspaces/file_workspace_service.py` | File CRUD test; Preview render test; AI apply diff test |
| **28/08 (T6)** | • Tách Graph View từ `ui/structure_graph_view.py`<br>• Lắp ráp shell FolderTab & ScheduleTab | ➔ `presentation/graph/structure_graph_view.py`<br>➔ `application/workspaces/graph_index_service.py`<br>➔ `presentation/scheduling/schedule_task_tab.py` | Graph RAG test; Smoke test: Folder & Schedule tabs mở tốt |
| **29/08 (T7)** | • Nối `ToolPolicyGateway` qua `core/mcp_client.py` & built-in tools<br>• Integration test Task Scheduler & File Explorer | ➔ `application/conversations/tool_policy_gateway.py` | Approval flow hoạt động chuẩn |
| **30/08 (CN)** | 🔍 **Chủ trì CASAN Check 2 (Single Responsibility Audit)**: Quét toàn bộ codebase đảm bảo không có file production nào > 400 dòng | ➔ Script count LOC | 0 file vi phạm (>400 lines) |
| **31/08 (T2)** | Fix tồn đọng Check 2, cập nhật README, merge PR cuối | — | CASAN Check 2 PASS |
---
### 🚦 Checkpoint Review & Cơ Chế Cổng Kiểm Duyệt CASAN (CASAN Verification Gate)
#### 🛡️ CASAN Là Gì?
**CASAN** là bộ cổng kiểm duyệt chất lượng và an toàn kiến trúc tự động (Automated Architectural Quality Gate) bắt buộc trước khi phát hành phiên bản tái cấu trúc. Tên viết tắt đại diện cho 5 nguyên tắc cốt lõi:
- **C** - **Clean Architecture (Ranh giới tầng sạch)**: Tầng `domain/` và `application/` tuyệt đối thuần Python, 0 phụ thuộc vào `PySide6` / Qt GUI framework.
- **A** - **Atomic Persistence (Lưu trữ an toàn & Bí mật)**: 0 lưu trữ plaintext API Key/Token trong JSON/config (phải dùng OS `SecretStore` / Keyring); mọi thao tác ghi dữ liệu tệp đều dùng cơ chế `AtomicJsonFile` chống hỏng dữ liệu khi crash.
- **S** - **Single Responsibility & Modularity (Kích thước tệp nhỏ gọn)**: Giới hạn tối đa **400 dòng code (LOC)** cho mỗi file production; mỗi file/class chỉ đảm nhận đúng 1 trách nhiệm duy nhất.
- **A** - **Automated Test Pyramid (Tháp kiểm thử tự động)**: Toàn bộ Unit tests (<1s), Contract tests, Integration tests chạy offline hoàn toàn không cần kết nối mạng hay Qt GUI event loop.
- **N** - **No Regression & E2E Smoke (Không hồi quy & Ổn định phát hành)**: Toàn bộ suite test hiện tại (>81 tests) và bộ E2E Smoke Test của ứng dụng chạy thành công 100% trên nhánh `main`.
#### 🔍 Chi Tiết 3 Cổng Kiểm Tra CASAN (Chạy Tự Động Ngày 30/08 & Pre-commit):
| Cổng Kiểm Tra | Mục Tiêu & Cơ Chế Kiểm Tra | Lệnh Chạy Kiểm Thử | Tiêu Chí Pass Bắt Buộc | Team Phụ Trách |
| :--- | :--- | :--- | :--- | :--- |
| **CASAN Check 1: Security Audit** | Quét regex phân tích tĩnh toàn bộ file cấu hình (`.json`, `.jsonl`, `.yaml`, `config.py`) nhằm phát hiện secret/token lưu plaintext | `python scripts/audit_security.py` | `0 plaintext secrets found` (100% key lưu qua Keyring) | **🟣 Team Nam** |
| **CASAN Check 2: Modularity (LOC Audit)** | Quét đếm số dòng code (LOC) của từng file trong `presentation/`, `application/`, `domain/`, `infrastructure/` | `python scripts/check_loc.py --max-lines 400` | `0 files exceeding 400 lines` (Tất cả God Files đã bị chia nhỏ) | **🟢 Team Hoa** |
| **CASAN Check 3: Clean Architecture Guard** | Dùng thư viện `ast` phân tích cây cú pháp trừu tượng, quét cấm các import `PySide6`, `PyQt*` bên trong `domain/` và `application/` | `python scripts/check_imports.py` | `0 Qt imports in business logic` | **🔵 Team Duy** |
| **Lệnh Tổng Hợp CASAN Gate** | Chạy toàn bộ 3 checks trên + suite `pytest` | `python scripts/run_quality_gate.py` | `ALL GATES PASSED (100%)` | **🔵 Team Duy (Tech Lead)** |
#### 📅 Bảng Kế Hoạch Checkpoint & CASAN Gate:
| Thời Điểm | Checkpoint | Tiêu Chí Đạt Bắt Buộc | Trách Nhiệm |
| :--- | :--- | :--- | :--- |
| **23/08 (CN - 17:00)** | ✅ **Checkpoint 1 (Contracts & Fakes)** | 100% DTO và Fake Services (`FakeProvider`, `FakeToolExecutor`, `FakeClock`) tạo xong; `pytest` pass; 0 team bị block | Cả 3 Team |
| **28/08 (T6 - 17:00)** | ✅ **Checkpoint 2 (Services & Sub-widgets)** | Tách xong 100% các God Files (`chat_panel.py`, `co4e_tab.py`, `folder_tab.py`, `monitoring_tab.py`, `schedule_task_tab.py`, `settings_dialog.py`); 0 circular import | Cả 3 Team |
| **30/08 (CN - 17:00)** | 🏁 **CASAN Verification Gate** | Chạy thành công đồng thời cả 3 checks: **CASAN Check 1** (Security), **CASAN Check 2** (LOC <400), **CASAN Check 3** (Import Guard) | Team Nam (Check 1)<br>Team Hoa (Check 2)<br>Team Duy (Check 3) |
| **31/08 (T2 - 15:00)** | 🎉 **Final Release Smoke Test** | Suite test (>81 tests) pass 100%; E2E smoke test 5 luồng chính hoạt động ổn định trên `main` | **Team Duy** (Chủ trì) & 3 Team |
---
### 📌 VI. MÔ TẢ CHI TIẾT 10 EPIC (R01 ➔ R10)
> [!TIP]
> 📋 Toàn bộ hệ thống checklist chi tiết từng đầu việc nhỏ (`R01-T01` ➔ `R10-T05`), checklist tiến độ theo ngày và tiêu chuẩn Definition of Done (DoD) đã được tách thành tài liệu theo dõi độc lập tại file **`Refactoring_Checklist.md`**.
---
#### 🔹 R01: Architecture Foundation & Characterization (Nền Tảng Kiến Trúc & Test Bảo Vệ)
* **Ý nghĩa & Mục tiêu**: Thiết lập luật phụ thuộc kiến trúc (Dependency Rules), xây dựng bộ fixtures/test doubles giả lập (`FakeProvider`, `FakeToolExecutor`) không phụ thuộc UI/mạng, và dựng script chặn vi phạm kiến trúc trên CI trước khi bất kỳ ai di chuyển mã nguồn.
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì ADR & Test Doubles) + Cả 3 Team.
* **Chi Tiết Cụ Thể Các Task Cần Làm Trong R01**:
1. **R01-T01: Soạn thảo Kiến trúc ADR (Layered Architecture ADR)**:
- Tạo `docs/architecture/ADR-001-layered-architecture.md` định rõ quy tắc 4 tầng: Presentation ➔ Application ➔ Domain ➔ Infrastructure.
- Quy định rõ ràng: `domain/` và `application/` chỉ chứa Pure Python, không chứa logic UI hoặc import `PySide6`.
2. **R01-T02: Xây dựng Bộ Fixtures & Test Doubles Offline (`tests/fakes/`)**:
- `tests/fakes/fake_provider.py`: Mock `BaseProvider`, trả về streaming text chunk và tool call events có thể kiểm soát được trong unit test.
- `tests/fakes/fake_tool_executor.py`: Mock bộ thực thi tool, trả về dummy result (đọc file, chạy lệnh) mà không can thiệp vào hệ thống tệp thật.
- Tiêu chuẩn: Unit test chạy hoàn tất < 1 giây, hoàn toàn độc lập với Qt GUI và network.
3. **R01-T03: Xây dựng Script Phân Tích AST Chặn Vi Phạm Kiến Trúc (`scripts/check_imports.py`)**:
- Dùng module `ast` quét toàn bộ file trong `domain/` và `application/`.
- Chặn các lệnh `import PySide6`, `import PyQt*`, `import app`.
- Tích hợp vào CI pipeline và Git pre-commit hook.
4. **R01-T04: Viết Characterization Tests cho Luồng Runtime Cốt Lõi (`tests/characterization/`)**:
- Tạo `tests/characterization/test_run_cowork.py`: Chụp snapshot hành vi hiện tại của hàm `core/chat_agent.py::run_cowork` (cách nhận input, gọi tool, tạo prompt).
- Đảm bảo khi tách sang `ConversationApplicationService` thì hành vi logic không bị sai lệch.
5. **R01-T05: Lập Danh Mục & Cô Lập Mã Nguồn Dormant/Dead Code (`docs/architecture/dormant-code.md`)**:
- Rà soát các module không còn active (như `LoginDialog`, `account` legacy) và đánh dấu cô lập, không để ảnh hưởng tới luồng tái cấu trúc chính.
---
#### 🔹 R02: Configuration, Secrets & Persistence (Cấu Hình Atomic & Bảo Mật Keyring)
* **Ý nghĩa & Mục tiêu**: Chuyển đổi cơ chế lưu trữ `config.py` sang ghi tệp an toàn (Atomic Write chống hỏng file khi crash), tạo Typed Settings Facades và đưa toàn bộ API Key/Token lưu plaintext sang OS Keyring (`SecretStore`).
* **Team chịu trách nhiệm**: 🟣 **Team Nam** (Chủ trì).
* **Chi Tiết Cụ Thể Các Task Cần Làm Trong R02**:
1. **R02-T01: Xây dựng Tiện Ích Ghi File Nguyên Tử (`AtomicJsonFile`)**:
- Tạo `infrastructure/persistence/json/atomic_json_file.py`: Ghi dữ liệu ra file tạm (`.tmp`), gọi `os.fsync()`, sau đó dùng `os.replace()` để thay thế file đích một cách an toàn.
- Thêm cơ chế tự động tạo bản sao lưu (`.bak`) khi phát hiện file JSON bị corrupt.
2. **R02-T02: Tái cấu trúc Kho Cấu Hình `ConfigRepository`**:
- Tạo `infrastructure/config/config_repository.py`: Đóng gói `config.py::AppConfig`, loại bỏ biến global dùng chung, chuyển sang Repository pattern có thread-safe lock.
3. **R02-T03: Xây dựng Typed Settings Facades Độc Lập**:
- Tạo `infrastructure/config/settings_facade.py`: Chia nhỏ cấu hình thành các dataclass định kiểu rõ ràng (`ProviderSettings`, `RoutingSettings`, `GeneralSettings`, `SecuritySettings`) thay vì truy xuất dictionary tự do.
4. **R02-T04: Định nghĩa Interface `SecretStore` & Cài đặt `KeyringAdapter`**:
- Tạo `infrastructure/secrets/keyring_adapter.py`: Sử dụng thư viện `keyring` của Python để lưu và đọc API Keys/Tokens từ Windows Credential Manager / macOS Keychain / Linux Secret Service.
- Thêm `tests/fakes/fake_keyring.py` để test môi trường CI không có UI desktop.
5. **R02-T05: Di Chuyển API Keys của Các Provider Sang `SecretStore`**:
- Xóa việc lưu plaintext `openai_api_key`, `anthropic_api_key`, `fpt_api_key` trong `config.json`.
- Tự động di chuyển (migrate) key cũ vào Keyring khi khởi động lần đầu.
6. **R02-T06: Chuẩn Hóa JSON Schema Versioning & Recovery Policy**:
- Bổ sung trường `schema_version` vào mọi file dữ liệu JSON (projects, tasks, routing assessment). Tự động chạy hàm migrate schema khi có phiên bản mới.
---
#### 🔹 R03: Model Providers & Routing (Hợp Nhất Nhà Cung Cấp & Bộ Định Tuyến Mô Hình)
* **Ý nghĩa & Mục tiêu**: Xóa bỏ sự phân tán logic định tuyến (hiện đang lặp lại ở `ui/chat_panel.py#L638`, `ui/co4e_tab.py`, `ui/folder_tab.py`) thành một `RoutingApplicationService` duy nhất; chuẩn hóa danh mục nhà cung cấp mô hình qua `ProviderDescriptor`.
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì).
* **Chi Tiết Cụ Thể Các Task Cần Làm Trong R03**:
1. **R03-T01: Xây dựng Bộ Contract Tests Chuẩn Hóa cho Model Providers**:
- Tạo `tests/contracts/test_providers.py`: Kiểm thử hợp đồng cho mọi provider (OpenAI, Anthropic, Ollama, FPT Gateway) để đảm bảo cùng tuân thủ interface `generate()`, `stream()`, `count_tokens()`.
2. **R03-T02: Định nghĩa `ProviderDescriptor` & Xây dựng `ProviderRegistry`**:
- Tạo `domain/models/provider_descriptor.py`: Dataclass định nghĩa metadata nhà cung cấp (id, name, models list, context length, pricing, required auth).
- Tạo `infrastructure/providers/provider_registry.py`: Registry đăng ký tập trung tất cả providers, hỗ trợ tra cứu động theo model ID.
3. **R03-T03: Xây dựng Dịch Vụ Định Tuyến `RoutingApplicationService` (Pure Python)**:
- Tạo `application/model_routing/routing_application_service.py` từ `core/routing/`: Điều phối 4 chế độ định tuyến (Off, Auto/Cost-effective, Manual, Fallback).
- Độc lập 100% với PySide6 UI, cho phép kiểm thử tự động toàn bộ rule routing mà không cần bật màn hình.
4. **R03-T04: Hợp Nhất Luồng Định Tuyến từ `ui/chat_panel.py#L638`**:
- Xóa bỏ logic routing sao chép trong `ui/chat_panel.py`, chuyển sang gọi trực tiếp qua `RoutingApplicationService`.
5. **R03-T05: Hợp Nhất Luồng Định Tuyến từ `ui/co4e_tab.py` & `ui/folder_tab.py`**:
- Chuyển đổi mọi lời gọi định tuyến mô hình trong Co4E Node Execution và AI File Editor sang dùng chung `RoutingApplicationService`.
6. **R03-T06: Tách Bóc Telemetry & Token Usage Thành `UsageEventSink`**:
- Tạo `infrastructure/telemetry/usage_sink.py`: Tách logic ghi nhận số lượng token và chi phí ra khỏi Provider, biến thành Event Subscriber lắng nghe sự kiện từ Application Service.
---
#### 🔹 R04: Agent Runtime & Conversation Application Service (Vòng Đời Turn Chat & Agent Engine)
* **Ý nghĩa & Mục tiêu**: Tách toàn bộ vòng đời thực thi 1 lượt chat (Turn) ra khỏi PySide6 UI; đóng gói dữ liệu đầu vào thành snapshot bất biến `ConversationExecutionRequest` và trả về luồng sự kiện `AgentEvent` có định kiểu.
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì).
* **Chi Tiết Cụ Thể Các Task Cần Làm Trong R04**:
1. **R04-T01: Định nghĩa Immutable Snapshot `ConversationExecutionRequest`**:
- Tạo `domain/agents/conversation_execution_request.py`: Chứa đầy đủ context của 1 lượt chạy (turn id, session id, user prompt, attachments, model config, tool capability scope, instructions).
- Dữ liệu bất biến (frozen dataclass), bảo đảm trong khi agent đang chạy, người dùng có đổi lựa chọn trên UI thì turn cũng không bị ảnh hưởng.
2. **R04-T02: Chuẩn hóa Hệ Thống Sự Kiện Luồng `AgentEvent`**:
- Tạo `domain/agents/agent_event.py`: Định nghĩa các sự kiện có kiểu dữ liệu mạnh: `TextChunkEvent`, `ToolCallStartedEvent`, `ToolCallFinishedEvent`, `TurnCompletedEvent`, `ErrorEvent`.
3. **R04-T03: Xây dựng `ConversationApplicationService`**:
- Tạo `application/conversations/conversation_application_service.py`: Tách logic từ `core/chat_agent.py`. Điều phối toàn bộ vòng đời của turn: chuẩn bị prompt ➔ gọi provider ➔ lắng nghe stream ➔ dispatch tool call ➔ tổng hợp câu trả lời ➔ lưu lịch sử hội thoại.
4. **R04-T04: Chuyển đổi `ui/cowork_tab.py::build_job`**:
- Thay thế logic tạo job phức tạp trong UI bằng việc khởi tạo `ConversationExecutionRequest` và gửi tới `ConversationApplicationService`.
5. **R04-T05: Đồng Bộ Hóa `core/task_executors.py` sang dùng chung Runtime**:
- Đưa việc thực thi chat của Scheduled Task Runner về dùng chung `ConversationApplicationService`, xoá bỏ duplicate agent runner.
---
#### 🔹 R05: Tool, MCP & Connector Policy (Quản Lý Công Cụ, MCP & Cổng Kiểm Soát Quyền)
* **Ý nghĩa & Mục tiêu**: Xóa bỏ giant if/elif dispatcher trong `core/tools.py`; đưa tất cả Built-in tools, MCP tools (`core/mcp_client.py`) và REST connectors (`core/ext_connectors.py`) qua cùng một cổng phân loại rủi ro (`ToolCapability`) và cổng phê duyệt bảo mật (`ToolPolicyGateway`).
* **Team chịu trách nhiệm**: 🟢 **Team Hoa** (Chủ trì) + Team Duy.
* **Chi Tiết Cụ Thể Các Task Cần Làm Trong R05**:
1. **R05-T01: Định nghĩa `ToolDescriptor`, `ToolCapability` & `ToolRegistry`**:
- Tạo `domain/tools/tool_descriptor.py`: Mô tả metadata công cụ (tên, mô tả, JSON Schema parameters, độ rủi ro READ / WRITE / EXECUTE / NETWORK).
- Tạo `domain/tools/tool_registry.py`: Kho đăng ký tập trung cho mọi công cụ hệ thống.
2. **R05-T02: Phân Rã Monolithic `core/tools.py` Thành Các Module Riêng Biệt**:
- Tạo `infrastructure/filesystem/file_tools.py` (read, write, edit, list_dir, grep).
- Tạo `infrastructure/filesystem/command_tools.py` (run_command, manage_task).
- Tạo `infrastructure/filesystem/fetch_tools.py` (read_url_content, search_web).
3. **R05-T03: Xây dựng Cổng Kiểm Soát Quyền `ToolPolicyGateway`**:
- Tạo `application/conversations/tool_policy_gateway.py`: Kiểm tra chính sách trước khi cho phép chạy tool (ALLOW, CONFIRM_REQUIRED, DENY). Khi cần xác nhận từ người dùng, phát tín hiệu yêu cầu phê duyệt thay vì gọi dialog trực tiếp trong hàm chạy ngầm.
4. **R05-T04: Chuẩn Hóa MCP Tools Qua `ToolPolicyGateway`**:
- Bọc các tool từ MCP Server (`core/mcp_client.py`) thành các `ToolDescriptor` tương thích để áp dụng cùng một chính sách an ninh như built-in tools.
5. **R05-T05: Xây dựng `McpToolSourceManager` Quản Lý Tiến Trình MCP**:
- Tạo `infrastructure/mcp/mcp_source_manager.py`: Quản lý vòng đời tiến trình MCP con (start, heartbeat, timeout, restart khi crash, graceful shutdown).
---
#### 🔹 R06: Workspace, Filesystem & History Isolation (Cô Lập Không Gian Làm Việc & Quản Lý Tệp)
* **Ý nghĩa & Mục tiêu**: Loại bỏ biến toàn cục `active_project_id` trong `state.py` gây xung đột dữ liệu giữa các luồng chạy ngầm; đóng gói không gian làm việc thành `WorkspaceSession` bất biến theo turn; bảo vệ an toàn đường dẫn tệp.
* **Team chịu trách nhiệm**: 🟢 **Team Hoa** (Chủ trì).
* **Chi Tiết Cụ Thể Các Task Cần Làm Trong R06**:
1. **R06-T01: Định nghĩa `WorkspaceSession` Đóng Gói Ngữ Cảnh**:
- Tạo `domain/workspaces/workspace_session.py`: Đối tượng snapshot chứa `project_id`, `workspace_root_path`, `sandbox_dir`, `allowed_paths`. Đảm bảo agent chỉ được đọc/ghi trong thư mục được cấp phép.
2. **R06-T02: Xây dựng `WorkspaceRepository` & `ConversationRepository`**:
- Tạo `infrastructure/persistence/json/workspace_repository_impl.py`: Quản lý danh sách dự án, cấu hình dự án (`core/projects.py`) bằng `AtomicJsonFile`.
- Lưu trữ và phân trang lịch sử chat (`core/history.py`) độc lập với UI sidebar.
3. **R06-T03: Xây dựng `ExecutionWorkspace` Quản Lý Tệp Output/Scratch**:
- Tạo `infrastructure/filesystem/execution_workspace.py`: Tách biệt thư mục workspace chính và thư mục scratch/output tạm thời của từng turn chạy.
4. **R06-T04: Khắc phục Race Condition trong `WorkspaceTab`**:
- Viết lại hàm `_load_current` trong `ui/workspace_tab.py`: Đồng bộ dữ liệu bằng session id thay vì đọc biến toàn cục `AppContext`.
5. **R06-T05: Xây dựng `FileWorkspaceService` cho File Explorer & AI Editor**:
- Tạo `application/workspaces/file_workspace_service.py`: Cung cấp API đọc cây thư mục, xem trước file đa định dạng, áp dụng AI code diffs an toàn.
---
#### 🔹 R07: Scheduling & Workflow Runtime (Bộ Lập Lịch & Động Cơ Quy Trình)
* **Ý nghĩa & Mục tiêu**: Tách biệt hoàn toàn tầng lưu trữ Task (`core/tasks.py`) và thuật toán tính toán lịch (`ScheduleCalculator`) khỏi `QTimer` trong `core/task_scheduler.py#L20`; xây dựng `TaskApplicationService` và `Co4EWorkflowService`.
* **Team chịu trách nhiệm**: 🟢 **Team Hoa** (Task Scheduling) + 🟣 **Team Nam** (Co4E Workflows).
* **Chi Tiết Cụ Thể Các Task Cần Làm Trong R07**:
1. **R07-T01: Tách `TaskRepository` Lưu Trữ JSON Độc Lập**:
- Tạo `infrastructure/persistence/json/task_repository_impl.py`: Đọc/ghi danh sách công việc (`tasks.json`) qua `AtomicJsonFile` với locking bảo vệ khi nhiều luồng cùng truy cập.
2. **R07-T02: Xây dựng Thuật Toán Tính Lịch `ScheduleCalculator`**:
- Tạo `domain/tasks/schedule_calculator.py`: Tính toán thời điểm chạy kế tiếp cho các dạng lịch: One-time, Interval, Daily, Weekly, Monthly, Cron Expression. Hoàn toàn là Pure Python, có unit test bao phủ 100%.
3. **R07-T03: Xây dựng Adapter `QtSchedulerClock`**:
- Tạo `platform/qt/qt_scheduler_clock.py`: Bọc `QTimer` vào Clock Interface. Cho phép trong unit test có thể thay thế bằng `FakeClock` để tua nhanh thời gian mà không cần chờ đợi.
4. **R07-T04: Xây dựng `TaskApplicationService` (Pure Python)**:
- Tạo `application/scheduling/task_application_service.py`: Điều phối toàn bộ nghiệp vụ quản lý task: CRUD task, kích hoạt chạy ngay (`run_now`), sao chép task, tạm dừng, xóa hàng loạt.
5. **R07-T05: Xây dựng `AiTaskPlannerService` Tạo Task Tự Động**:
- Tạo `application/scheduling/ai_task_planner_service.py`: Phân tích câu lệnh tự nhiên của người dùng để sinh ra cấu hình task và lịch chạy tương ứng.
6. **R07-T06: Xây dựng `Co4EWorkflowService` Động Cơ Quy Trình Node**:
- Tạo `application/workflows/co4e_workflow_service.py`: Tách logic thực thi đồ thị node từ `core/co4e_run_manager.py`. Quản lý state của từng node, truyền dữ liệu giữa các node và xử lý retry/error.
---
#### 🔹 R08: UI/Application Separation (Phân Rã Toàn Diện Các God Widgets)
* **Ý nghĩa & Mục tiêu**: Tách nhỏ toàn bộ các màn hình khổng lồ (>1.500 - 2.000 dòng) thành các widget con chuyên trách, đảm bảo mỗi file < 400 dòng và chỉ đảm nhận hiển thị / bắt sự kiện giao diện.
* **Team chịu trách nhiệm**: **Cả 3 Team** (Mỗi team phụ trách phân hệ của mình):
* **Chi Tiết Cụ Thể Các Task Cần Làm Trong R08**:
1. **🔵 Team Duy – Tách `ChatPanel` (`ui/chat_panel.py` >1.800 dòng) thành 6 widgets con**:
- `R08-T01`: `presentation/chat/chat_history_widget.py` (Render bong bóng chat, streaming markdown, tool call cards).
- `R08-T02`: `presentation/chat/composer_widget.py` (Ô nhập liệu text, phím tắt Ctrl+Enter, auto-resize).
- `R08-T03`: `presentation/chat/attachment_picker.py` (Widget chọn file, ảnh, folder đính kèm).
- `R08-T04`: `presentation/chat/audio_recorder_widget.py` (Widget ghi âm giọng nói & chuyển thành văn bản).
- `R08-T05`: `presentation/chat/chat_output_panel.py` (Panel hiển thị file output sinh ra trong turn).
- `R08-T06`: `presentation/chat/chat_panel.py` (Shell container điều phối các widget con & `Floating HelpAgent`).
2. **🟣 Team Nam – Tách `SettingsDialog`, `MonitoringTab`, `Co4ETab` & Shell `MainWindow`**:
- `R08-T07`: `presentation/settings/` ➔ Tách thành `provider_settings_widget.py`, `connector_settings_widget.py`, `routing_settings_widget.py`, `general_settings_widget.py`.
- `R08-T08`: `presentation/monitoring/` ➔ Tách 8 tab con thành từng file: `overview_tab.py`, `sandbox_status_tab.py`, `security_events_tab.py`, `mcp_history_tab.py`, `action_logs_tab.py`, `agents_admin_tab.py`, `security_settings_tab.py`, `tools_admin_tab.py`.
- `R08-T09`: `presentation/co4e/` ➔ Tách thành `co4e_canvas_widget.py`, `node_property_panel.py`, `co4e_run_control_widget.py`, `co4e_chat_view.py`.
- `R08-T10`: `presentation/shell/` ➔ Xây dựng `bootstrap.py` (Composition Root) và tách `app.py::MainWindow` thành `main_window.py`, `tray_manager.py`, `lifecycle_coordinator.py`.
3. **🟢 Team Hoa – Tách `ScheduleTaskTab`, `FolderTab`, `DashboardTab` & `StructureGraphView`**:
- `R08-T11`: `presentation/scheduling/` ➔ Tách thành `kanban_board_widget.py` (7 cột kéo thả), `calendar_view_widget.py`, `ai_task_creator_dialog.py`, `ai_task_import_dialog.py`.
- `R08-T12`: `presentation/folder/` ➔ Tách thành `workspace_file_tree.py`, `document_preview_manager.py` (PDF/Word/Excel/Images), `ai_file_editor_dialog.py`.
- `R08-T13`: `presentation/dashboard/` ➔ Tách thành `token_usage_card_widget.py`, `usage_chart_widget.py`, `habits_widget.py`.
- `R08-T14`: `presentation/graph/` ➔ Tách thành `structure_graph_view.py` & `graph_qa_widget.py`.
---
#### 🔹 R09: Security Runtime, Sandbox & Observability (An Ninh Runtime, Sandbox & Giám Sát)
* **Ý nghĩa & Mục tiêu**: Phân biệt rõ ràng giữa quy tắc bảo mật bắt buộc (Enforced Deterministic Rules) và các gợi ý bảo mật từ AI (Advisory Guardrails); loại bỏ circular imports; chuẩn hóa định dạng log kiểm toán canonical.
* **Team chịu trách nhiệm**: 🟣 **Team Nam** (Chủ trì) + 🔵 **Team Duy**.
* **Chi Tiết Cụ Thể Các Task Cần Làm Trong R09**:
1. **R09-T01: Chuẩn Hóa Security Policy Model**:
- Tạo `docs/architecture/security-policy.md`: Phân định ranh giới giữa bộ lọc quy tắc cứng (regex cấm xóa tệp hệ thống, cấm truy cập thư mục ngoài sandbox) và bộ đánh giá rủi ro mềm từ LLM.
2. **R09-T02: Xử Lý Triệt Để Circular Import `model_pricing` ↔ `usage_tracker`**:
- Tách DTO giá mô hình (`ModelPricing`) vào `domain/models/` để cả `model_pricing.py` và `usage_tracker.py` cùng import xuôi mà không import vòng tròn.
3. **R09-T03: Xử Lý Triệt Để Circular Import `agent_security` ↔ `agent_security_alert`**:
- Tách các enum và event cảnh báo bảo mật (`SecurityAlertEvent`) sang `domain/security/` để xoá hoàn toàn import chéo.
4. **R09-T04: Xây Dựng `CanonicalAuditLogger` Thống Nhất Định Dạng Log**:
- Tạo `infrastructure/telemetry/audit_logger.py`: Chuẩn hóa schema nhật ký (timestamp UTC, actor, action, resource, outcome, latency) ghi ra file JSON Lines an toàn.
5. **R09-T05: Xây Dựng `MonitoringQueryService` Truy Vấn Dữ Liệu Read-Only**:
- Tạo `application/monitoring/monitoring_query_service.py`: Cung cấp API truy vấn log kiểm toán có phân trang, lọc theo thời gian, lọc theo mức độ nghiêm trọng (severity).
6. **R09-T06: Chuẩn Hóa Ma Trận Năng Lực Sandbox Trên Từng Hệ Điều Hành**:
- Tạo `infrastructure/sandbox/sandbox_capabilities.py`: Tách biệt cơ chế cách ly thực tế: Windows (Job Objects / AppContainer), Linux (Namespaces / Bubblewrap), macOS (Sandbox-exec).
---
#### 🔹 R10: Testing, Packaging & Contributor Experience (Hệ Thống Kiểm Thử & Tài Liệu Đóng Góp)
* **Ý nghĩa & Mục tiêu**: Đây là **Task trọng tâm cốt lõi của Team Duy (Tech Lead)** nhằm thiết lập hệ thống bảo vệ toàn diện cho dự án: xây dựng tháp kiểm thử 4 tầng (Unit, Contract, Integration, E2E Smoke), cài đặt CI Quality Gate tự động, soạn thảo bộ công thức Contributor Recipes và thực hiện kiểm thử khói tổng thể trước khi release.
* **Team chịu trách nhiệm**: 🔵 **Team Duy (Chủ Trì Chính - Task Trọng Tâm Của Team Duy)**.
* **Chi Tiết Cụ Thể Các Task Cần Làm Trong R10**:
1. **R10-T01: Xây dựng Tháp Kiểm Thử Phân Tầng (Test Pyramid Architecture - `tests/`)**:
- `tests/unit/`: Kiểm thử các logic độc lập không I/O (Domain entities, `ScheduleCalculator`, `AtomicJsonFile`, parsing). Thời gian chạy: < 0.05s/test.
- `tests/contracts/`: Bộ test xác thực interface chuẩn của Provider API (`test_providers.py`) và Tool Handler (`test_tools.py`) để các provider mới chỉ cần pass contract là cắm vào được ngay.
- `tests/integration/`: Kiểm thử phối hợp nhiều tầng không cần UI (`test_chat_flow.py`, `test_workflow_execution.py`, `test_task_scheduling.py`).
- `tests/fakes/`: Thư viện test doubles tái sử dụng cho cả 3 team (`FakeProvider`, `FakeToolExecutor`, `FakeClock`, `FakeKeyringAdapter`).
2. **R10-T02: Xây Dựng Bộ Script CI Quality Gate Tự Động (`scripts/`)**:
- `scripts/check_imports.py`: Script phân tích AST kiểm tra chặn 100% import `PySide6` trong `domain/` và `application/`.
- `scripts/check_loc.py`: Script quét LOC tự động cảnh báo lỗi nếu có bất kỳ file nào > 400 dòng code.
- `scripts/audit_security.py`: Script quét phát hiện secret/API Key plaintext trong toàn bộ codebase.
- `scripts/run_quality_gate.py`: Script tổng hợp chạy 1 lệnh duy nhất để kiểm tra toàn bộ tiêu chí CASAN Gate trước khi merge PR.
3. **R10-T03: Cập Nhật Tài Liệu Dự Án & Hướng Dẫn Thiết Lập (`README.md`, `START_CONTRIBUTING.md`)**:
- Cập nhật sơ đồ kiến trúc 4 tầng chuẩn (Presentation ➔ Application ➔ Domain ➔ Infrastructure).
- Hướng dẫn cài đặt môi trường phát triển local, chạy test và cấu hình Git pre-commit hook để chạy script kiểm tra tự động.
4. **R10-T04: Soạn Thảo Bộ Contributor Recipes (`docs/governance/contributor-recipes.md`)**:
- Hướng dẫn mẫu từng bước kèm code mẫu:
- *Recipe 1*: "Cách thêm một Model Provider mới" (Khai báo `ProviderDescriptor`, tạo Adapter trong `infrastructure/providers/`, chạy Contract Test).
- *Recipe 2*: "Cách thêm một Built-in Tool hoặc MCP Tool mới" (Khai báo `ToolDescriptor`, đăng ký capability, cấu hình `ToolPolicyGateway`).
- *Recipe 3*: "Cách thêm một Màn hình / Sub-widget mới" (Tạo Widget trong `presentation/`, kết nối Application Service qua Qt Signals, tuân thủ giới hạn <400 LOC).
5. **R10-T05: Bộ Kiểm Thử Khói Phát Hành E2E (Release Smoke Test - `tests/e2e/test_smoke.py`)**:
- Khởi động ứng dụng qua `bootstrap.py` ở chế độ headless Qt offscreen và thực thi tự động 5 kịch bản chính:
1. Khởi tạo chat session, gửi tin nhắn và nhận stream event từ `FakeProvider`.
2. Tạo mới task trên Kanban, trigger chạy task và xác nhận ghi log.
3. Mở File Explorer, tạo file tạm trong `WorkspaceSession` và đọc nội dung an toàn.
4. Tạo workflow 2 node trên Co4E Studio và kích hoạt chạy thử.
5. Mở Settings Dialog, cấu hình mock provider API Key và kiểm tra lưu thành công vào `SecretStore`.
- Tiêu chí hoàn thành: 100% 5 kịch bản E2E pass, không xung đột luồng và ứng dụng thoát sạch sẽ.
---
## 📊 VII. BẢNG PHÂN CÔNG, KPI & QUY TRÌNH PHỐI HỢP LIÊN TEAM
### 1. Bảng Phân Công & KPI Đo Lường Thành Công
| Team | Phân Hệ Chính | Trách Nhiệm Cụ Thể | KPI Đo Lường Hoàn Thành |
| :--- | :--- | :--- | :--- |
| **🔵 Team Duy**<br>*(Tech Lead)* | **Core AI, Routing & Testing** | • R01 ADR & Runtime test doubles<br>• R03 Provider Registry & Unified Routing<br>• R04 ConversationApplicationService<br>• R08 Tách ChatPanel thành 5 sub-widgets<br>• **R10 Testing Pyramid, Contributor Recipes & Smoke Test**<br>• Chủ trì CASAN Check 3 | • 0 PySide6 import trong `application/conversations` và `application/model_routing`<br>• 0 file >400 dòng trong `presentation/chat/`<br>• Bộ test pyramid >81 tests pass 100%<br>• CASAN Check 3 PASS |
| **🟣 Team Nam** | **Workflows & Governance** | • R02 Atomic Config & Keyring SecretStore<br>• R08 Tách Settings (4 sections) & Monitoring (7 tabs)<br>• R08 Tách Co4E Tab & Co4EWorkflowService<br>• Composition Root (`bootstrap.py`) & MainWindow Shell<br>• R09 Security Policy Model & Fix Circular Imports<br>• Chủ trì CASAN Check 1 | • 0 plaintext credential/API Key trong JSON<br>• 0 file >400 dòng trong `presentation/co4e/`, `monitoring/`, `settings/`<br>• CASAN Check 1 PASS |
| **🟢 Team Hoa** | **Workspace & Tools** | • R05 ToolRegistry & phân rã `core/tools.py`<br>• R06 WorkspaceSession & isolation<br>• R07 TaskApplicationService & QtSchedulerClock<br>• R08 Tách FolderTab, ScheduleTaskTab, DashboardTab, Graph<br>• Chủ trì CASAN Check 2 | • 0 file >400 dòng trong `presentation/folder/`, `scheduling/`, `dashboard/`, `graph/`<br>• Task Scheduler chạy độc lập không phụ thuộc Qt GUI<br>• CASAN Check 2 PASS |
---
### 2. Quy Trình Phối Hợp & Phòng Ngừa Xung Đột (Collaboration Protocol)
1. **Quy tắc Branching & PR:**
* Mỗi team làm việc trên prefix branch riêng biệt:
* Team Duy: `duy/chat-routing-tests-*`
* Team Nam: `nam/workflow-governance-*`
* Team Hoa: `hoa/workspace-tools-*`
* Mọi PR trước khi merge vào nhánh chung (`develop`/`main`) phải kèm theo unit tests và đảm bảo suite test hiện tại không bị regression.
2. **Quy tắc Mocking liên team (Không chờ đợi):**
* Nếu Team Duy (Chat) cần kích hoạt task ➔ gọi qua interface `TaskApplicationService` (dùng `FakeTaskApplicationService` trong test do Team Hoa cung cấp DTO).
* Nếu Team Hoa (File Editor / Graph RAG) cần gọi model ➔ gọi qua `RoutingApplicationService` / `FakeProvider` do Team Duy chốt DTO từ Ngày 1.
* Nếu Team Nam (Co4E Runner) cần gọi Tool ➔ gọi qua `ToolPolicyGateway` do Team Hoa cung cấp.
* Không team nào được chặn (block) tiến độ của team khác.
3. **Tiêu chuẩn hoàn thành PR (Definition of Done - DoD):**
* File mới hoặc sau refactor không vượt quá **400 dòng code**.
* Không import `PySide6` trong `domain/` và `application/`.
* Credentials/API Keys được lưu trữ qua `SecretStore` (Keyring), không lưu plaintext trong `config.json`.
* **Bắt buộc comment code bằng Tiếng Anh (English In-code Comments)**: Mỗi dòng hoặc khối code sửa đổi/thêm mới phải có chú thích bằng tiếng Anh giải thích rõ mục đích và lý do kỹ thuật.
* **Ghi nhận thời gian thực hiện (Task Start/End Timestamps)**: Mọi task khi bắt đầu phải log ngày giờ Start, khi xong phải log ngày giờ End vào `Refactoring_Checklist.md` và PR description.
* Chi tiết đối chiếu tại checklist `Refactoring_Checklist.md`.
> [!IMPORTANT]
> ### 📝 QUY ĐỊNH BẮT BUỘC KHI CODE & THEO DÕI TIẾN ĐỘ:
> 1. **In-Code Comments in English**: Ở mỗi dòng hoặc đoạn code được chỉnh sửa/bóc tách, lập trình viên **bắt buộc phải viết comment bằng tiếng Anh** giải thích rõ logic xử lý và lý do kiến trúc (rationale). Ví dụ:
> ```python
> # Extract immutable snapshot request to decouple execution lifecycle from PySide6 UI
> request = ConversationExecutionRequest.from_composer_state(...)
> ```
> 2. **Task Start/End Timestamps**:
> - Khi bắt đầu task ➔ Ghi nhận thời gian: `Start: YYYY-MM-DD HH:mm`.
> - Khi hoàn tất & test pass ➔ Ghi nhận thời gian: `End: YYYY-MM-DD HH:mm`.
> - Ghi nhận đầy đủ vào checklist theo dõi tại `Refactoring_Checklist.md` để đảm bảo tính minh bạch và tiến độ của cả 3 team.
## 🚫 VIII. NHỮNG GÌ KHÔNG LÀM (Anti-patterns)
> [!WARNING]
> Để tránh over-engineering và rewrite không kiểm soát, nhóm phải tuân thủ:
- ❌ **Không di chuyển file ngay** trước khi có contract và test bảo vệ.
- ❌ **Không dựng event bus toàn ứng dụng** hoặc DI framework phức tạp.
- ❌ **Không bắt mọi class phải có interface** — chỉ introduce contract tại seam có nhiều caller.
- ❌ **Không rewrite đồng thời** Cowork + Co4E + Folder + Scheduler trong 1 PR.
- ❌ **Không gọi là "frontend/backend"** — đây là desktop single-process.
- ❌ **Không unify Flow/Co4E** trước khi semantics được ghi rõ và có contract tests.
- ❌ **Không xóa candidate dead code** (LoginDialog, account modules) trộn vào PR refactor — phải PR riêng.
---
## 🗺️ IX. BẢN ĐỒ DI CHUYỂN FUNCTION (FUNCTION MIGRATION MAP)
> Dựa trực tiếp từ `function_list.md`. Mỗi function hiện tại được ánh xạ đến file mới sau khi chia nhỏ.
> **Quy ước**: 🎨 = `presentation/` | 📋 = `application/` | 🧠 = `domain/` | 🔧 = `infrastructure/`
### Dashboard (Section 1 trong function_list.md)
| Function Hiện Tại | File Mới | Tầng |
| :--- | :--- | :--- |
| `_refresh_cards()` | `presentation/dashboard/token_usage_card_widget.py` | 🎨 |
| `_refresh_chart()`, `_chart_prev()`, `_chart_next()`, `_on_gran_changed()` | `presentation/dashboard/usage_chart_widget.py` | 🎨 |
| `_refresh_budget()`, `_apply_budget()` | `presentation/dashboard/token_usage_card_widget.py` | 🎨 |
| `_refresh_habits()` | `presentation/dashboard/habits_widget.py` | 🎨 |
| `_ai_analyze()`, `_apply_saving_strategy()` | `application/monitoring/dashboard_query_service.py` | 📋 |
| Currency Picker | `presentation/dashboard/token_usage_card_widget.py` | 🎨 |
### Schedule Task (Section 2 trong function_list.md)
| Function Hiện Tại | File Mới | Tầng |
| :--- | :--- | :--- |
| `_build_kanban()`, `_render_kanban()`, `_on_task_dropped()` | `presentation/scheduling/kanban_board_widget.py` | 🎨 |
| `_on_card_double_click()`, `_on_card_right_click()`, `_bulk_delete_menu()` | `presentation/scheduling/kanban_board_widget.py` | 🎨 |
| `_search_tasks()`, `_filter_by_type()` | `presentation/scheduling/kanban_board_widget.py` | 🎨 |
| `_run_now(task_id)`, `_duplicate_task()`, `_pause_task()`, `_delete_task()` | `application/scheduling/task_application_service.py` | 📋 |
| `_view_logs(task_id)` | `presentation/scheduling/kanban_board_widget.py` → gọi MonitoringQueryService | 🎨 |
| `_build_calendar()`, `_shift()`, `add_task_on_date()`, `edit_task()` | `presentation/scheduling/calendar_view_widget.py` | 🎨 |
| `_open_add_dialog()` | `presentation/scheduling/schedule_task_tab.py` (container) | 🎨 |
| `_ai_create_task()`, `_ai_pick_files()`, `_generate()`, `_on_planned()`, `_confirm()` | `presentation/scheduling/ai_task_creator_dialog.py` | 🎨 |
| `_ai_import()`, `_ai_pick_import_files()`, `_generate_import()`, `_on_import_planned()` | `presentation/scheduling/ai_task_import_dialog.py` | 🎨 |
| AI generation logic | `application/scheduling/ai_task_planner_service.py` | 📋 |
### Workspace / Cowork Chat (Section 3.2.1 trong function_list.md)
| Function Hiện Tại | File Mới | Tầng |
| :--- | :--- | :--- |
| `new_session()` | `application/conversations/conversation_application_service.py` | 📋 |
| `send_message()` → `_submit_message()` | `presentation/chat/composer_widget.py` (UI trigger) | 🎨 |
| `_build_job()` → `ConversationExecutionRequest` | `application/conversations/conversation_application_service.py` | 📋 |
| `_cleanup_turn()`, `_promote_turn_outputs()` | `application/conversations/conversation_application_service.py` | 📋 |
| `_refresh_outputs_from_disk()`, `_pick_output_folder()` | `presentation/chat/chat_output_panel.py` | 🎨 |
| `_open_skills_manager()` | `presentation/chat/chat_panel.py` (container) | 🎨 |
| `refresh_header()` | `presentation/chat/chat_panel.py` (container) | 🎨 |
| `refresh_agents()` | `presentation/chat/chat_panel.py` (combo widget) | 🎨 |
| `admin_agent_prompt()` | `application/conversations/conversation_application_service.py` | 📋 |
| `build_provider()` | `infrastructure/providers/provider_factory.py` | 🔧 |
| `workspace_dir()` | `domain/workspaces/workspace_session.py` | 🧠 |
| `_start_watching()`, `_on_file_changed()` | `presentation/chat/chat_output_panel.py` | 🎨 |
| `_on_turn_started()`, `_on_turn_finished()`, `_on_event(ev)` | `presentation/chat/chat_history_widget.py` (event renderer) | 🎨 |
| `_compress_messages()` | `application/conversations/conversation_application_service.py` | 📋 |
| `_apply_routing()` | `application/model_routing/routing_application_service.py` | 📋 |
| `_on_agent_changed()`, `_note_agent_switch()` | `presentation/chat/chat_panel.py` | 🎨 |
| `_ensure_conversation()`, `load_conversation()`, `_save_conversation()` | `application/conversations/conversation_application_service.py` | 📋 |
| `running_session_ids()`, `active_workers()` | `application/conversations/conversation_application_service.py` | 📋 |
| `send()`, `attach_files()`, `attach_links()` | `presentation/chat/composer_widget.py` | 🎨 |
| `has_any_queue()`, `_parse_directives()`, `_show_autocomplete()` | `presentation/chat/composer_widget.py` | 🎨 |
### Co4E Workflow Studio (Section 3.2.2 trong function_list.md)
| Function Hiện Tại | File Mới | Tầng |
| :--- | :--- | :--- |
| `_build_sidebar()`, `_build_canvas()`, `_build_config_panel()`, `_toggle_config()` | `presentation/co4e/co4e_tab.py` (container) | 🎨 |
| `_refresh_flows_list()`, `_create_flow()`, `_delete_flow()`, `_duplicate_flow()` | `application/workflows/co4e_workflow_service.py` | 📋 |
| `_import_flow()`, `_export_flow()` | `application/workflows/co4e_workflow_service.py` | 📋 |
| `_run_flow()`, `_stop_flow()` | `application/workflows/co4e_workflow_service.py` | 📋 |
| `_open_flow()` | `presentation/co4e/co4e_tab.py` → gọi canvas | 🎨 |
| `_refresh_agents_list()`, `_create_agent()`, `_edit_agent()`, `_delete_agent()`, `_toggle_agent_enabled()` | `presentation/co4e/agent_list_panel.py` | 🎨 |
| `_refresh_skills_list()` | `presentation/co4e/skills_list_panel.py` | 🎨 |
| `zoom_in()`, `zoom_out()`, `fit_view()` | `presentation/co4e/co4e_canvas_widget.py` | 🎨 |
| `_add_node()`, `_delete_node()`, `_connect_nodes()`, `_drag_node()`, `_select_node()`, `_activate_node()` | `presentation/co4e/co4e_canvas_widget.py` | 🎨 |
| `_set_run_mode()`, `_run_step()`, `_on_step_finished()`, `_render_plan()` | `presentation/co4e/co4e_run_control_widget.py` | 🎨 |
| `_get_flow_chat()`, `_on_chat_event()` | `presentation/co4e/co4e_chat_view.py` | 🎨 |
### Folder / File Explorer (Section 3.2.3 trong function_list.md)
| Function Hiện Tại | File Mới | Tầng |
| :--- | :--- | :--- |
| `set_root()`, `_build_tree_view()` | `presentation/folder/workspace_file_tree.py` | 🎨 |
| `_open_file()`, `_view_source()`, `_view_html_preview()`, `_view_office_doc()`, `_view_image()` | `presentation/folder/document_preview_manager.py` | 🎨 |
| `_edit_file()`, `_save_file()`, `_preview_toggle()` | `presentation/folder/workspace_file_tree.py` | 🎨 |
| `_create_new_file()`, `_create_new_folder()`, `_rename_item()`, `_delete_item()`, `_copy_item()`, `_paste_item()` | `presentation/folder/workspace_file_tree.py` | 🎨 |
| `refresh_ai_models()` | `presentation/folder/folder_tab.py` (container) | 🎨 |
| `_ai_send()`, `_ai_discard()`, `_reset_ai_conversation()` | `presentation/folder/ai_file_editor_dialog.py` | 🎨 |
| `_ai_apply()` | `application/workspaces/file_workspace_service.py` | 📋 |
| `_ai_apply_routing()` | `application/model_routing/routing_application_service.py` | 📋 |
### Graph RAG (Section 3.2.4 trong function_list.md)
| Function Hiện Tại | File Mới | Tầng |
| :--- | :--- | :--- |
| `_build_graph()` | `application/workspaces/graph_index_service.py` | 📋 |
| `_render_d3_graph()`, `_render_native_graph()`, `_auto_rotate()` | `presentation/graph/structure_graph_view.py` | 🎨 |
| `_on_node_click()`, `_open_node_path()`, `_refresh_graph()` | `presentation/graph/structure_graph_view.py` | 🎨 |
| `_search_graph()`, `_filter_by_kind()`, `_zoom_graph()` | `presentation/graph/structure_graph_view.py` | 🎨 |
| `_ask_question()`, `_on_ask_event()`, `_on_ask_done()` | `presentation/graph/graph_qa_widget.py` | 🎨 |
| `_candidate_file_paths()`, `_extract_tmp_dir()`, `_clear_extracts()` | `application/workspaces/graph_index_service.py` | 📋 |
### Monitoring (Section 4 trong function_list.md)
| Function Hiện Tại | File Mới | Tầng |
| :--- | :--- | :--- |
| `_refresh_overview()`, `_refresh_usage_cards()`, `_refresh_resource_usage()`, `_refresh_recent_activity()` | `presentation/monitoring/overview_tab.py` | 🎨 |
| `_refresh_sandbox_details()`, `_refresh_permissions()`, `_refresh_audit_log()` | `presentation/monitoring/sandbox_status_tab.py` | 🎨 |
| `_refresh_budget()`, `_apply_budget()` | `presentation/monitoring/overview_tab.py` | 🎨 |
| `_refresh_security_events()`, `_filter_security_events()`, `_sort_events()` | `presentation/monitoring/security_events_tab.py` | 🎨 |
| `_refresh_mcp_calls()`, `_filter_mcp_calls()` | `presentation/monitoring/mcp_history_tab.py` | 🎨 |
| `_refresh_action_logs()`, `_filter_action_logs()`, `_sort_action_logs()` | `presentation/monitoring/action_logs_tab.py` | 🎨 |
| `_refresh_agent_status()` | `presentation/monitoring/agent_status_tab.py` | 🎨 |
| `_toggle_sandbox()`, `_toggle_network_block()`, `_set_resource_limits()`, `_toggle_command_confirm()`, `_manage_permissions()` | `presentation/monitoring/security_settings_tab.py` | 🎨 |
| Query/refresh data logic | `application/monitoring/monitoring_query_service.py` | 📋 |
### Settings (Section 5 trong function_list.md)
| Function Hiện Tại | File Mới | Tầng |
| :--- | :--- | :--- |
| `_on_provider_changed()`, `_load_models()`, `_test_connection()` | `presentation/settings/provider_settings_widget.py` | 🎨 |
| `_stash_provider_fields()`, `_apply_provider_fields()`, Model List Widget | `presentation/settings/provider_settings_widget.py` | 🎨 |
| `_add_mcp_server()`, `_edit_mcp_server()`, `_delete_mcp_server()`, `_test_mcp_connection()` | `presentation/settings/connector_settings_widget.py` | 🎨 |
| MS365, CAD/CAE Connectors | `presentation/settings/connector_settings_widget.py` | 🎨 |
| `routing_mode`, `routing_policy`, `routing_min_gain`, `routing_timeout`, `routing_interval`, `routing_concurrency`, `routing_judge` | `presentation/settings/routing_settings_widget.py` | 🎨 |
| Language Picker, `tray_chk`, `notify_chk` | `presentation/settings/general_settings_widget.py` | 🎨 |
| `_save()` | `application/settings/settings_application_service.py` | 📋 |
| `attach_tokens`, `attach_files`, `struct_nodes`, `struct_edges` | `presentation/settings/general_settings_widget.py` | 🎨 |
| Provider test connection (network call) | `infrastructure/providers/provider_factory.py` | 🔧 |
| MCP test connection (network call) | `infrastructure/mcp/mcp_client.py` | 🔧 |
---
📄 Tài liệu này là bản hợp nhất chính thức. Cập nhật: **14/08/2026** (bổ sung Function Migration Map từ `function_list.md`).
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

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

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+1175
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+12
View File
@@ -0,0 +1,12 @@
"""Domain layer - pure Python entities, value objects and events.
The innermost layer of the 4-tier architecture (see
``docs/architecture/ADR-001-layered-architecture.md``). Modules here describe
WHAT the application is about - a turn of conversation, a model candidate, an
agent event - and depend on nothing but the standard library.
Hard rule (ADR-001 I1/I2, enforced by ``scripts/check_imports.py``): no imports
of PySide6/PyQt, and no imports from ``application/``, ``infrastructure/``,
``presentation/`` or the legacy ``core/``/``ui/`` packages. That is what keeps
this layer testable in milliseconds and reusable from a headless scheduler.
"""
+48
View File
@@ -0,0 +1,48 @@
"""Domain entities for one agent turn: the request snapshot and the typed event
stream it produces (EPIC R04)."""
from .agent_event import (
AgentEvent,
AssistantDoneEvent,
ErrorEvent,
HistoryReadyEvent,
NoticeEvent,
OutputsAddedEvent,
OutputsRemovedEvent,
PlanUpdatedEvent,
ReasoningChunkEvent,
TextChunkEvent,
ToolCallFinishedEvent,
ToolCallStartedEvent,
ToolOutputEvent,
TurnCompletedEvent,
collect_text,
event_from_dict,
tool_calls,
)
from .conversation_execution_request import (
ConversationExecutionRequest,
new_turn_id,
)
__all__ = [
"ConversationExecutionRequest",
"new_turn_id",
"AgentEvent",
"TextChunkEvent",
"ReasoningChunkEvent",
"AssistantDoneEvent",
"PlanUpdatedEvent",
"ToolCallStartedEvent",
"ToolOutputEvent",
"ToolCallFinishedEvent",
"OutputsAddedEvent",
"OutputsRemovedEvent",
"NoticeEvent",
"HistoryReadyEvent",
"TurnCompletedEvent",
"ErrorEvent",
"event_from_dict",
"collect_text",
"tool_calls",
]
+370
View File
@@ -0,0 +1,370 @@
"""AgentEvent - the typed event stream one agent turn produces (R04-T02).
Today the turn engine talks to its caller through untyped dicts::
emit({"type": "tool_result", "id": tc_id, "name": name,
"ok": result.get("ok", False), "output": result.get("output", "")})
and every consumer re-discovers the vocabulary by reading the producer. There
are eleven such shapes across ``core/chat_agent.py``, ``core/code_agent.py`` and
``core/task_executors.py``; a consumer that misspells ``"tool_result"`` or reads
``"result"`` instead of ``"output"`` fails silently, at runtime, only for the
tool path that triggers it.
This module makes the vocabulary explicit. Each event is a frozen dataclass, so:
* the set of possible events is enumerable (see :data:`EVENT_TYPES`);
* a field name typo is an ``AttributeError`` at the point of use, not a silently
missing chat bubble;
* an event can cross a thread boundary safely - it cannot be mutated after the
producer hands it over, which is exactly what the Qt-signal seam needs.
Bridging with the legacy dicts is deliberate and two-way: :func:`event_from_dict`
adapts what ``run_cowork`` emits today, and :meth:`AgentEvent.to_dict` renders an
event back into the legacy shape so existing widgets keep working untouched
while the presentation layer migrates screen by screen (EPIC R08).
Pure domain code: stdlib only, no Qt, no I/O.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
@dataclass(frozen=True)
class AgentEvent:
"""Base class for everything a turn can report.
``type`` is the legacy string tag, kept as a class attribute so the bridge
functions can round-trip an event without a separate mapping table.
"""
type: str = field(init=False, default="event")
def to_dict(self) -> Dict[str, Any]:
"""Render into the legacy ``emit()`` dict shape."""
return {"type": self.type}
# --------------------------------------------------------------------------- #
# Assistant output
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class TextChunkEvent(AgentEvent):
"""One fragment of the visible answer, as it streams in."""
delta: str
type: str = field(init=False, default="text")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "delta": self.delta}
@dataclass(frozen=True)
class ReasoningChunkEvent(AgentEvent):
"""One fragment of the model's PRIVATE reasoning.
Drives the "Thinking" indicator only. Consumers must never append this to
the answer or persist it into conversation history - keeping it a distinct
type is what makes that mistake hard to make by accident.
"""
delta: str
type: str = field(init=False, default="reasoning")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "delta": self.delta}
@dataclass(frozen=True)
class AssistantDoneEvent(AgentEvent):
"""One assistant message finished. A turn with tool calls emits this once
per step, not once per turn - see :class:`TurnCompletedEvent`."""
content: str = ""
type: str = field(init=False, default="assistant_done")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "content": self.content}
# --------------------------------------------------------------------------- #
# Planning
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class PlanUpdatedEvent(AgentEvent):
"""The agent rewrote its plan (the ``update_plan`` tool)."""
steps: Tuple[Dict[str, Any], ...] = ()
type: str = field(init=False, default="plan_set")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "steps": [dict(s) for s in self.steps]}
# --------------------------------------------------------------------------- #
# Tool lifecycle
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class ToolCallStartedEvent(AgentEvent):
"""A tool call is about to run, with the preview shown to the user.
Maps the legacy ``tool_proposed`` event. "Proposed" was a misnomer: by the
time it is emitted the call is already going to run unless a permission gate
rejects it, and the gate reports that as a finished call with ``ok=False``.
"""
call_id: str
name: str
args: Dict[str, Any] = field(default_factory=dict)
preview: Optional[Dict[str, Any]] = None
type: str = field(init=False, default="tool_proposed")
def to_dict(self) -> Dict[str, Any]:
out: Dict[str, Any] = {"type": self.type, "id": self.call_id,
"name": self.name, "args": dict(self.args)}
if self.preview is not None:
out["preview"] = dict(self.preview)
return out
@dataclass(frozen=True)
class ToolOutputEvent(AgentEvent):
"""A line of live output from a running tool (command stdout, for example)."""
call_id: str
name: str
delta: str
type: str = field(init=False, default="tool_output")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "id": self.call_id, "name": self.name,
"delta": self.delta}
@dataclass(frozen=True)
class ToolCallFinishedEvent(AgentEvent):
"""A tool call ended, successfully or not.
``ok=False`` covers every failure mode alike - the tool raised, the sandbox
blocked it, or the user rejected it at the permission gate - because the
consumer's job is the same in all three: show the failure and let the model
react to it.
"""
call_id: str
name: str
ok: bool = False
output: str = ""
path: str = "" # file the tool wrote, when it wrote one
produced: Tuple[str, ...] = () # extra artefacts (e.g. a generator's outputs)
type: str = field(init=False, default="tool_result")
def to_dict(self) -> Dict[str, Any]:
out: Dict[str, Any] = {"type": self.type, "id": self.call_id, "name": self.name,
"ok": self.ok, "output": self.output}
if self.path:
out["path"] = self.path
if self.produced:
out["produced"] = list(self.produced)
return out
# --------------------------------------------------------------------------- #
# Output folder
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class OutputsAddedEvent(AgentEvent):
"""Files appeared in the turn's output folder."""
paths: Tuple[str, ...] = ()
type: str = field(init=False, default="outputs_added")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "paths": list(self.paths)}
@dataclass(frozen=True)
class OutputsRemovedEvent(AgentEvent):
"""Files were cleaned up from the turn's output folder (intermediates)."""
paths: Tuple[str, ...] = ()
type: str = field(init=False, default="outputs_removed")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "paths": list(self.paths)}
@dataclass(frozen=True)
class NoticeEvent(AgentEvent):
"""A UI-visible aside that is not part of the model's answer.
Three producers today, all reachable from a normal turn:
``core/agent_security.py`` (a request or command blocked by the security
layer), ``core/context_budget.py`` (the conversation was auto-compressed)
and the attachment readers (a file that could not be processed, plus live
"reading page X/Y" progress).
``level`` selects how the UI renders it: ``"progress"`` updates the thinking
indicator in place, anything else becomes a warning bubble. Dropping these
would silently hide security warnings from the user, which is why the type
exists rather than being folded into TextChunkEvent.
"""
text: str
level: str = "info"
type: str = field(init=False, default="notice")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "level": self.level, "text": self.text}
@dataclass(frozen=True)
class HistoryReadyEvent(AgentEvent):
"""A history session exists for this run and can be opened."""
session_id: str
type: str = field(init=False, default="history_ready")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "session_id": self.session_id}
# --------------------------------------------------------------------------- #
# Turn lifecycle - emitted by the application service, not by the legacy engine
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class TurnCompletedEvent(AgentEvent):
"""The whole turn finished: no more events will follow.
New in R04. The legacy engine has no end-of-turn signal at all, so every
consumer infers "done" from the worker thread finishing - which is why a
cancelled turn and a failed turn look identical to the UI today.
"""
content: str = ""
cancelled: bool = False
type: str = field(init=False, default="turn_completed")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "content": self.content, "cancelled": self.cancelled}
@dataclass(frozen=True)
class ErrorEvent(AgentEvent):
"""The turn failed. ``recoverable`` marks errors the user can act on
(pick another model, shorten the prompt) rather than a hard outage."""
message: str
recoverable: bool = False
type: str = field(init=False, default="error")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "message": self.message,
"recoverable": self.recoverable}
# The legacy tag -> event class map. Also the authoritative list of what a turn
# can emit, which is what makes an exhaustive consumer possible for the first time.
EVENT_TYPES: Dict[str, type] = {
"text": TextChunkEvent,
"reasoning": ReasoningChunkEvent,
"assistant_done": AssistantDoneEvent,
"plan_set": PlanUpdatedEvent,
"tool_proposed": ToolCallStartedEvent,
"tool_start": ToolCallStartedEvent,
"tool_output": ToolOutputEvent,
"tool_result": ToolCallFinishedEvent,
"outputs_added": OutputsAddedEvent,
"outputs_removed": OutputsRemovedEvent,
"notice": NoticeEvent,
"history_ready": HistoryReadyEvent,
"turn_completed": TurnCompletedEvent,
"error": ErrorEvent,
}
def event_from_dict(payload: Mapping[str, Any]) -> Optional[AgentEvent]:
"""Adapt one legacy ``emit()`` dict into a typed event.
Returns ``None`` for an unknown tag instead of raising: the legacy engine is
still being refactored and may grow an event before this module knows about
it. Dropping an unrecognised event degrades the UI by one missing bubble;
raising here would abort a turn that had otherwise succeeded.
"""
kind = str(payload.get("type", ""))
cls = EVENT_TYPES.get(kind)
if cls is None:
return None
if cls is TextChunkEvent or cls is ReasoningChunkEvent:
return cls(delta=str(payload.get("delta", "")))
if cls is AssistantDoneEvent:
return AssistantDoneEvent(content=str(payload.get("content", "")))
if cls is PlanUpdatedEvent:
return PlanUpdatedEvent(steps=tuple(payload.get("steps") or ()))
if cls is ToolCallStartedEvent:
return ToolCallStartedEvent(
call_id=str(payload.get("id", "")), name=str(payload.get("name", "")),
args=dict(payload.get("args") or {}), preview=payload.get("preview"),
)
if cls is ToolOutputEvent:
return ToolOutputEvent(call_id=str(payload.get("id", "")),
name=str(payload.get("name", "")),
delta=str(payload.get("delta", "")))
if cls is ToolCallFinishedEvent:
return ToolCallFinishedEvent(
call_id=str(payload.get("id", "")), name=str(payload.get("name", "")),
ok=bool(payload.get("ok", False)), output=str(payload.get("output", "")),
path=str(payload.get("path", "") or ""),
produced=tuple(payload.get("produced") or ()),
)
if cls is OutputsAddedEvent or cls is OutputsRemovedEvent:
return cls(paths=tuple(str(p) for p in (payload.get("paths") or ())))
if cls is NoticeEvent:
return NoticeEvent(text=str(payload.get("text", "")),
level=str(payload.get("level", "info")))
if cls is HistoryReadyEvent:
return HistoryReadyEvent(session_id=str(payload.get("session_id", "")))
if cls is TurnCompletedEvent:
return TurnCompletedEvent(content=str(payload.get("content", "")),
cancelled=bool(payload.get("cancelled", False)))
return ErrorEvent(message=str(payload.get("message", "")),
recoverable=bool(payload.get("recoverable", False)))
def collect_text(events: Sequence[AgentEvent]) -> str:
"""Join every :class:`TextChunkEvent` - the visible answer, reasoning excluded.
Provided here so no consumer has to re-derive "which events are the answer",
the question the untyped dicts made easy to get wrong.
"""
return "".join(e.delta for e in events if isinstance(e, TextChunkEvent))
def tool_calls(events: Sequence[AgentEvent]) -> List[ToolCallFinishedEvent]:
"""Every finished tool call, in order - for audit views and assertions."""
return [e for e in events if isinstance(e, ToolCallFinishedEvent)]
__all__ = [
"AgentEvent",
"TextChunkEvent",
"ReasoningChunkEvent",
"AssistantDoneEvent",
"PlanUpdatedEvent",
"ToolCallStartedEvent",
"ToolOutputEvent",
"ToolCallFinishedEvent",
"OutputsAddedEvent",
"OutputsRemovedEvent",
"NoticeEvent",
"HistoryReadyEvent",
"TurnCompletedEvent",
"ErrorEvent",
"EVENT_TYPES",
"event_from_dict",
"collect_text",
"tool_calls",
]
@@ -0,0 +1,192 @@
"""ConversationExecutionRequest - an immutable snapshot of one turn (R04-T01).
``ui/cowork_tab.py::build_job`` currently builds a closure that reads widget
state from inside the worker thread::
def job(worker):
provider = self.build_provider() # reads combo boxes
extra_tools, extra_exec = self.ctx.build_mcp_tools()
proj_ctx = project_context_text(load_project(project_id))
...
Everything that closure touches can change while the turn is running: the user
can pick another model, switch workspace, or edit the project instructions. The
turn then runs on a mixture of old and new state, and which mixture depends on
thread timing - the class of bug that reproduces once a week and never in a test.
This value object is the fix: the presentation layer captures everything a turn
needs ON THE UI THREAD, at submit time, into one frozen object. Whatever happens
to the widgets afterwards, the turn keeps running on the state the user actually
submitted.
Pure domain code: stdlib only, no Qt, no filesystem access. Paths are held as
strings, not ``Path`` objects, so the snapshot stays trivially serialisable -
which is what will let a turn be queued, replayed or logged later.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field, replace
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
# Default tool-use budget for an interactive turn, and the higher ceiling a
# run-to-completion step (a Co4E flow step) is allowed. Same numbers
# ``core.chat_agent.run_cowork`` defaults to - kept here so the policy is
# visible in the request rather than buried in a function signature.
DEFAULT_MAX_STEPS = 30
DEFAULT_COMPLETION_MAX_STEPS = 200
def new_turn_id() -> str:
"""A fresh turn id. Short and random: it only has to be unique within a
session's lifetime, and it shows up in log lines humans read."""
return uuid.uuid4().hex[:12]
@dataclass(frozen=True)
class ConversationExecutionRequest:
"""Everything one agent turn needs, captured at submit time.
Attributes:
prompt: the user's message for this turn (already assembled, including
any attachment text the UI inlined).
messages: the full conversation to send, oldest first. Held as a tuple
so the snapshot cannot be mutated after capture; use
:meth:`message_list` to get the mutable copy the engine expects.
output_dir: this turn's OWN folder. Each turn writes into an isolated
directory so parallel turns cannot clobber each other's files.
session_id: the conversation this turn belongs to.
turn_id: unique per turn, for logs and for matching events to a turn.
surface: which screen submitted it ("cowork", "co4e", "ai_edit", "task").
provider / model: what to run on, already resolved (routing included).
Empty ``model`` means "the provider's configured default".
title: conversation title, used to name generated files.
project_id / project_context: the workspace and its shared instructions,
snapshotted so a mid-turn workspace switch cannot change them.
agent_role: audit-log attribution for every tool call this turn makes.
allowed_tools: permission scope. ``None`` means "all enabled tools";
a list restricts the ADVERTISED catalogue, so a read-only step
literally cannot be offered a writing tool.
max_steps / run_to_completion / completion_max_steps: tool-use budget.
enforce_rules: run the security rulebase. Co4E sandboxed runs disable it.
confirm_commands: ask before run_command/install_package (permission gate).
metadata: free-form extras a caller wants carried along (never
interpreted here) - e.g. a scheduled task's id.
"""
prompt: str
messages: Tuple[Mapping[str, Any], ...] = ()
output_dir: str = ""
session_id: str = ""
turn_id: str = field(default_factory=new_turn_id)
surface: str = "cowork"
provider: str = ""
model: str = ""
title: str = ""
project_id: str = ""
project_context: str = ""
agent_role: str = ""
allowed_tools: Optional[Tuple[str, ...]] = None
max_steps: int = DEFAULT_MAX_STEPS
run_to_completion: bool = False
completion_max_steps: int = DEFAULT_COMPLETION_MAX_STEPS
enforce_rules: bool = True
confirm_commands: bool = False
metadata: Mapping[str, Any] = field(default_factory=dict)
# -- construction helpers ------------------------------------------- #
@classmethod
def create(cls, prompt: str, messages: Optional[Sequence[Mapping[str, Any]]] = None,
**kwargs: Any) -> "ConversationExecutionRequest":
"""Build a request from ordinary mutable inputs.
The messages list is copied element by element, so a later append by the
caller (the chat panel keeps appending to its own list) cannot reach
inside a request that is already running.
"""
snapshot = tuple(dict(m) for m in (messages or ()))
allowed = kwargs.pop("allowed_tools", None)
return cls(prompt=prompt, messages=snapshot,
allowed_tools=tuple(allowed) if allowed is not None else None,
**kwargs)
def with_messages(self, messages: Sequence[Mapping[str, Any]]
) -> "ConversationExecutionRequest":
"""A copy carrying a different message list, everything else unchanged.
Used when a caller assembles the system prompt or trims history after
building the request - it must produce a NEW snapshot rather than mutate
the one a turn may already be running on.
"""
return replace(self, messages=tuple(dict(m) for m in messages))
def with_model(self, provider: str, model: str) -> "ConversationExecutionRequest":
"""A copy pinned to another provider/model - how a routing switch is
applied without touching the user's saved settings."""
return replace(self, provider=provider, model=model)
# -- accessors ------------------------------------------------------ #
def message_list(self) -> List[Dict[str, Any]]:
"""A fresh mutable copy of the messages, for the engine to append to.
The legacy engine mutates the list it is given (it inserts the system
prompt and appends assistant/tool messages). Handing it a copy is what
keeps this snapshot immutable in practice and not just by declaration.
"""
return [dict(m) for m in self.messages]
@property
def effective_max_steps(self) -> int:
"""The tool-use ceiling actually in force for this turn."""
return self.completion_max_steps if self.run_to_completion else self.max_steps
@property
def has_output_dir(self) -> bool:
"""True when this turn may write files."""
return bool(self.output_dir)
def allows_tool(self, name: str) -> bool:
"""Whether ``name`` is inside this turn's permission scope.
``update_plan`` is always allowed: it has no side effects and drives the
Plan panel, so scoping it out would silently break the UI rather than
restrict a capability.
"""
if self.allowed_tools is None:
return True
return name == "update_plan" or name in self.allowed_tools
def describe(self) -> str:
"""Compact one-line identity for log lines."""
target = f"{self.provider}/{self.model}" if self.model else self.provider or "default"
return f"turn={self.turn_id} surface={self.surface} model={target}"
def to_dict(self) -> Dict[str, Any]:
"""JSON-safe projection, for logging a turn or persisting it for replay."""
return {
"turn_id": self.turn_id,
"session_id": self.session_id,
"surface": self.surface,
"prompt": self.prompt,
"message_count": len(self.messages),
"output_dir": self.output_dir,
"provider": self.provider,
"model": self.model,
"title": self.title,
"project_id": self.project_id,
"agent_role": self.agent_role,
"allowed_tools": list(self.allowed_tools) if self.allowed_tools is not None else None,
"max_steps": self.effective_max_steps,
"run_to_completion": self.run_to_completion,
"enforce_rules": self.enforce_rules,
"confirm_commands": self.confirm_commands,
"metadata": dict(self.metadata),
}
__all__ = [
"ConversationExecutionRequest",
"new_turn_id",
"DEFAULT_MAX_STEPS",
"DEFAULT_COMPLETION_MAX_STEPS",
]
+5
View File
@@ -0,0 +1,5 @@
"""Domain models: provider/model catalogue value objects (EPIC R03)."""
from .provider_descriptor import ProviderCapability, ProviderDescriptor
__all__ = ["ProviderDescriptor", "ProviderCapability"]
+171
View File
@@ -0,0 +1,171 @@
"""ProviderDescriptor - the declarative catalogue entry for one model provider (R03-T02).
Today the knowledge of "what a provider is" is scattered across three places
that must be edited together and can silently drift apart:
* ``providers/factory.py::_REGISTRY`` - name -> implementation class
* ``config.py::DEFAULT_CONFIG["providers"]`` - default base_url / model / api_key
* ``config.py::PROVIDER_LABELS`` - the human label shown in Settings
Adding a provider means remembering all three; forgetting one produces a
provider that exists but has no label, or a label with no implementation. This
value object folds those facts into a single immutable description that the
registry (``infrastructure/providers/provider_registry.py``) and the UI can both
read, so a new provider is declared once.
Pure domain code: stdlib only, no Qt, no network, no config access. It describes
a provider; building one is infrastructure's job.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, FrozenSet, List, Mapping, Optional, Tuple
class ProviderCapability(str, Enum):
"""What a provider can do, as advertised by its descriptor.
Kept as a closed enum rather than free-form strings so a typo
(``"vison"``) fails at import time instead of silently disabling a feature
at runtime. Inherits ``str`` so existing dict/JSON code that compares against
plain strings keeps working during the migration.
"""
STREAMING = "streaming" # can stream answer fragments through on_text
TOOLS = "tools" # can be given a ToolSpec catalogue and call tools
VISION = "vision" # accepts image content blocks (see providers/base.py)
REASONING = "reasoning" # emits a separate private "thinking" stream
MODEL_LISTING = "model_listing" # list_models() returns a real catalogue
@dataclass(frozen=True)
class ProviderDescriptor:
"""An immutable description of one provider the app can talk to.
Attributes:
id: the config key, e.g. ``"openai_compat"``. Also the ``provider`` half
of a routing candidate key (``provider/model_id``).
label: human-readable name for Settings and the model picker.
protocol: which wire format this provider speaks. Several ids share one
protocol - ``ollama``, ``github_copilot`` and ``codex`` are all
OpenAI-compatible endpoints - which is exactly why protocol and id
must be separate fields.
default_model: the model used when the user has not chosen one.
capabilities: what the provider supports (see :class:`ProviderCapability`).
requires_api_key: whether an empty ``api_key`` makes it unusable.
requires_base_url: whether an empty ``base_url`` makes it unusable.
local: True when the endpoint runs on the user's own machine. Routing
treats local models as zero-cost, and the security layer treats them
as not leaving the machine, so this is a real behavioural flag and
not just documentation.
notes: free-form remark shown in Settings (e.g. "paste a Copilot token").
"""
id: str
label: str
protocol: str
default_model: str = ""
capabilities: FrozenSet[ProviderCapability] = field(default_factory=frozenset)
requires_api_key: bool = True
requires_base_url: bool = True
local: bool = False
notes: str = ""
# -- capability queries ---------------------------------------------- #
def supports(self, capability: ProviderCapability) -> bool:
"""True when this provider advertises ``capability``."""
return capability in self.capabilities
@property
def supports_vision(self) -> bool:
"""Mirrors ``providers.base.Provider.supports_vision`` so callers can ask
the descriptor (no instance, no network) before building a provider."""
return self.supports(ProviderCapability.VISION)
@property
def supports_tools(self) -> bool:
"""True when this provider can run an agent turn with tools. A provider
without it can still chat, but must never be routed a tool-using task."""
return self.supports(ProviderCapability.TOOLS)
def capability_names(self) -> List[str]:
"""Capabilities as sorted plain strings - the shape the routing layer's
``required_capabilities`` filter and the assessment store both use."""
return sorted(c.value for c in self.capabilities)
# -- configuration validation ---------------------------------------- #
def missing_settings(self, conf: Mapping[str, Any]) -> List[str]:
"""Which required config keys are absent or blank in ``conf``.
Returned as a list (not a bool) so Settings can tell the user exactly
what to fill in, instead of a generic "not configured". A provider that
needs nothing returns an empty list.
"""
missing: List[str] = []
if self.requires_api_key and not str(conf.get("api_key", "") or "").strip():
missing.append("api_key")
if self.requires_base_url and not str(conf.get("base_url", "") or "").strip():
missing.append("base_url")
return missing
def is_configured(self, conf: Mapping[str, Any]) -> bool:
"""True when ``conf`` carries everything this provider needs to run."""
return not self.missing_settings(conf)
def resolve_model(self, conf: Optional[Mapping[str, Any]] = None,
requested: str = "") -> str:
"""Pick the model id for a call: explicit request, else configured, else
this descriptor's default.
Centralised here because the same three-step fallback is currently
re-implemented at every call site (chat panel, Co4E, AI-edit, scheduler),
and each of them gets the precedence subtly different.
"""
if requested:
return requested
configured = str((conf or {}).get("model", "") or "").strip()
return configured or self.default_model
def describe(self, conf: Optional[Mapping[str, Any]] = None) -> str:
"""One-line summary for logs and the Settings row, e.g.
``"anthropic:claude-sonnet-4-6 (Anthropic Claude)"``."""
return f"{self.id}:{self.resolve_model(conf)} ({self.label})"
def candidate_key(self, model_id: str) -> str:
"""The ``provider/model_id`` identity the routing layer keys on.
Defined here so the domain owns the format; ``core.routing.models`` has
its own ``candidate_key()`` helper producing the identical string, and
keeping them equal is what lets the new registry and the existing
assessment store share one keyspace during the migration.
"""
return f"{self.id}/{model_id}"
def to_dict(self) -> Dict[str, Any]:
"""JSON-safe projection, for persisting a catalogue snapshot or sending
the descriptor to a UI layer that must not import domain types."""
return {
"id": self.id,
"label": self.label,
"protocol": self.protocol,
"default_model": self.default_model,
"capabilities": self.capability_names(),
"requires_api_key": self.requires_api_key,
"requires_base_url": self.requires_base_url,
"local": self.local,
"notes": self.notes,
}
def split_candidate_key(key: str) -> Tuple[str, str]:
"""Inverse of :meth:`ProviderDescriptor.candidate_key`.
Splits on the FIRST ``/`` only: some gateways expose model ids that contain
a slash (``org/model``), and splitting on the last one would corrupt them.
"""
provider, _, model_id = key.partition("/")
return provider, model_id
__all__ = ["ProviderCapability", "ProviderDescriptor", "split_candidate_key"]
+166 -33
View File
@@ -178,9 +178,27 @@ STRINGS: Dict[str, Dict[str, str]] = {
"app.nav.collapse_tooltip": {"en": "Collapse menu to icons only", "ja": "メニューをアイコンのみに折りたたむ", "vi": "Thu gọn menu về icon"},
"app.nav.expand_tooltip": {"en": "Expand menu", "ja": "メニューを展開", "vi": "Mở rộng menu"},
"app.nav.menu_label": {"en": "MENU", "ja": "MENU", "vi": "MENU"},
# Shown on the rail rows the project gate disables (Cowork, GraphRAG) —
# they stay listed and greyed instead of disappearing from the menu.
"app.nav.needs_project": {
"en": "Select a project first", "ja": "先にプロジェクトを選択してください",
"vi": "Chọn project trước"},
# Rail header: the project a new chat will be created in, and what to do
# when there is no project yet.
"app.nav.project_pick": {
"en": "Project for new chats", "ja": "新しいチャットのプロジェクト",
"vi": "Project cho đoạn chat mới"},
"app.nav.no_project": {
"en": "No project yet", "ja": "プロジェクトなし", "vi": "Chưa có project"},
"app.nav.recents": {"en": "RECENTS", "ja": "最近", "vi": "GẦN ĐÂY"},
"app.nav.all_projects": {
"en": "All projects…", "ja": "すべてのプロジェクト…", "vi": "Tất cả project…"},
"app.nav.create_project_first": {
"en": "Create a project first", "ja": "先にプロジェクトを作成してください",
"vi": "Tạo project trước"},
# ---- workspace_tab.py (Projects — Claude-Projects style) -----------
"workspace.header": {"en": "Workspace — Projects", "ja": "ワークスペース — プロジェクト", "vi": "Workspace — Projects"},
"workspace.header": {"en": "Manage projects", "ja": "プロジェクト管理", "vi": "Quản lý project"},
"workspace.tab_cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
"workspace.tab_graphrag": {"en": "GraphRAG", "ja": "GraphRAG", "vi": "GraphRAG"},
"workspace.tab_project": {"en": "Project", "ja": "プロジェクト", "vi": "Project"},
@@ -360,6 +378,12 @@ STRINGS: Dict[str, Dict[str, str]] = {
"các file đặt ở gốc thư mục đó (project knowledge)."),
},
"workspace.new_project": {"en": "New project", "ja": "新規プロジェクト", "vi": "Project mới"},
"workspace.projects_heading": {"en": "PROJECTS", "ja": "プロジェクト", "vi": "PROJECT"},
"workspace.folder_label": {"en": "Workspace folder", "ja": "作業フォルダ", "vi": "Thư mục làm việc"},
"workspace.counts": {
"en": "{chats} chats · {tasks} tasks",
"ja": "チャット {chats} · タスク {tasks}",
"vi": "{chats} đoạn chat · {tasks} task"},
"workspace.delete": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
"workspace.delete_confirm": {
"en": "Delete project “{name}”? Its conversations and files are kept (threads move to General).",
@@ -373,19 +397,19 @@ STRINGS: Dict[str, Dict[str, str]] = {
"vi": "Project của hội thoại này không còn tồn tại — không thể mở."},
"workspace.name": {"en": "Name", "ja": "名前", "vi": "Tên"},
"workspace.description": {"en": "Description", "ja": "説明", "vi": "Mô tả"},
"workspace.instructions": {"en": "Instructions (shared project context)", "ja": "指示(プロジェクト共有コンテキスト)", "vi": "Instructions (ngữ cảnh chung của project)"},
"workspace.instructions": {"en": "Instructions", "ja": "Instructions", "vi": "Instructions"},
"workspace.instructions_placeholder": {
"en": "e.g. \"All answers in Vietnamese. We are building the X reporting tool; always follow the naming rules …\"",
"ja": "例:「回答はすべて日本語で。X レポートツールを開発中。命名規則に従うこと …」",
"vi": "vd: \"Trả lời bằng tiếng Việt. Team đang xây tool báo cáo X; luôn theo quy tắc đặt tên …\"",
},
"workspace.browse": {"en": "Change folder…", "ja": "フォルダ変更…", "vi": "Đổi thư mục…"},
"workspace.browse": {"en": "Change", "ja": "変更", "vi": "Đổi"},
"workspace.browse_tooltip": {
"en": "Choose the project's workspace folder (agent sandbox + shared knowledge root)",
"ja": "プロジェクトのワークスペースフォルダを選択(エージェントのサンドボックス+共有ナレッジのルート)",
"vi": "Chọn thư mục workspace của project (sandbox của agent + gốc chứa knowledge chung)",
},
"workspace.open_folder": {"en": "Open folder", "ja": "フォルダを開く", "vi": "Mở thư mục"},
"workspace.open_folder": {"en": "Open", "ja": "開く", "vi": "Mở"},
"workspace.save": {"en": "Save project", "ja": "プロジェクトを保存", "vi": "Lưu project"},
"workspace.saved": {"en": "Saved project {name}.", "ja": "プロジェクト {name} を保存しました。", "vi": "Đã lưu project {name}."},
"workspace.threads": {"en": "Conversations in this project", "ja": "このプロジェクトの会話", "vi": "Hội thoại trong project này"},
@@ -468,7 +492,9 @@ STRINGS: Dict[str, Dict[str, str]] = {
"chat.assistant": {"en": "Assistant", "ja": "アシスタント", "vi": "Assistant"},
"chat.error": {"en": "Error", "ja": "エラー", "vi": "Lỗi"},
"help_agent.title": {
"en": "App Assistant", "ja": "アプリアシスタント", "vi": "Trợ lý App"},
# The audit page names this AI Assistant, and keeps it the same in every
# language — it is a product name, not a description.
"en": "AI Assistant", "ja": "AI Assistant", "vi": "AI Assistant"},
"help_agent.greeting": {
"en": "Hello {name}, have a great working day! How can I help you use the app?",
"ja": "こんにちは {name} さん、良い一日を!アプリの使い方について何かお手伝いできますか?",
@@ -478,16 +504,27 @@ STRINGS: Dict[str, Dict[str, str]] = {
"en": "Ask how to use the app…", "ja": "アプリの使い方を質問…",
"vi": "Hỏi cách sử dụng app…"},
"help_agent.open_tooltip": {
"en": "App Assistant — help using the app",
"ja": "アプリアシスタント — アプリの使い方をサポート",
"vi": "Trợ lý App — hỗ trợ sử dụng app"},
"en": "AI Assistant — help using the app",
"ja": "AI Assistant — アプリの使い方をサポート",
"vi": "AI Assistant — hỗ trợ sử dụng app"},
"help_agent.collapse_tooltip": {
"en": "Minimize", "ja": "最小化", "vi": "Thu nhỏ"},
"help_agent.hide_tooltip": {
"en": "Hide to the edge", "ja": "端に隠す", "vi": "Ẩn vào cạnh phải"},
"help_agent.dot_hint": {
"en": "right-click to hide",
"ja": "右クリックで非表示",
"vi": "chuột phải để ẩn"},
# The name on the launcher pill. Deliberately the same in every language —
# it is a product name, and it only shows on hover, so length is not a
# constraint the way it was on a permanently visible badge.
"help_agent.badge": {
"en": "AI Assistant", "ja": "AI Assistant", "vi": "AI Assistant"},
"help_agent.more_tooltip": {
"en": "More", "ja": "その他", "vi": "Thêm"},
"help_agent.show_tooltip": {
"en": "Show the App Assistant", "ja": "アプリアシスタントを表示",
"vi": "Hiện App Assistant"},
"en": "Show the AI Assistant", "ja": "AI Assistant を表示",
"vi": "Hiện AI Assistant"},
"help_agent.empty_reply": {
"en": "(no answer)", "ja": "(回答なし)", "vi": "(không có phản hồi)"},
"help_agent.error": {
@@ -886,6 +923,14 @@ STRINGS: Dict[str, Dict[str, str]] = {
"schedtask.script_placeholder": {
"en": "(script tasks only) e.g. python report.py", "ja": "(Scriptタスクのみ)例: python report.py",
"vi": "(chỉ task Script) vd: python report.py"},
# The title/description block at the top of the Task editor had no name
# either — needed once the index had to list it.
"schedtask.g_basic": {"en": "Basics", "ja": "基本", "vi": "Thông tin chung"},
# The three steps the editor is split into: what to do, when, and what it
# connects to. Each holds the same group boxes as before.
"schedtask.step_content": {"en": "Content", "ja": "内容", "vi": "Nội dung"},
"schedtask.step_schedule": {"en": "Schedule", "ja": "スケジュール", "vi": "Lịch chạy"},
"schedtask.step_link": {"en": "Links", "ja": "連携", "vi": "Liên kết"},
"schedtask.g_schedule": {"en": "Schedule Setup", "ja": "スケジュール設定", "vi": "Thiết lập lịch chạy"},
"schedtask.sched_enable": {"en": "Enable schedule", "ja": "スケジュールを有効化", "vi": "Bật lịch chạy"},
"schedtask.f_run_at": {"en": "Run at", "ja": "実行日時", "vi": "Chạy lúc"},
@@ -1241,7 +1286,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"dashboard.card_in": {"en": "Input", "ja": "入力", "vi": "Input"},
"dashboard.card_out": {"en": "Output", "ja": "出力", "vi": "Output"},
"dashboard.card_cache": {"en": "Cache", "ja": "キャッシュ", "vi": "Cache"},
"dashboard.card_cost": {"en": "Total cost", "ja": "合計コスト", "vi": "Tổng chi phí"},
"dashboard.card_cost": {"en": "Cost", "ja": "コスト", "vi": "Chi phí"},
"dashboard.card_turns": {"en": "{n} turns", "ja": "{n} ターン", "vi": "{n} lượt"},
"dashboard.prices_label": {
"en": "Unit price (USD / 1M tokens):", "ja": "単価 (USD / 100万トークン):",
@@ -1276,7 +1321,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"dashboard.ref_last_week": {"en": "Last week", "ja": "先週", "vi": "Tuần trước"},
"dashboard.ref_last_month": {"en": "Last month", "ja": "先月", "vi": "Tháng trước"},
"dashboard.ref_last_year": {"en": "Last year", "ja": "昨年", "vi": "Năm trước"},
"usage.budget_title": {"en": "Budget", "ja": "予算", "vi": "Budget"},
"usage.budget_title": {"en": "Budget", "ja": "予算", "vi": "Ngân sách"},
"usage.budget_no_budget": {"en": "No budget set", "ja": "予算未設定", "vi": "Chưa đặt Budget"},
"usage.budget_used_pct": {"en": "{pct}% used", "ja": "{pct}% 使用済み", "vi": "Đã dùng {pct}%"},
"usage.budget_over_warning": {"en": "⚠ Over 85% of budget used",
@@ -1441,6 +1486,9 @@ STRINGS: Dict[str, Dict[str, str]] = {
"vi": "Hiện thông báo khay hệ thống khi tác vụ xong hoặc lỗi"},
"settings.group.openai": {"en": "OpenAI-compatible (Internal Gateway)", "ja": "OpenAI 互換(社内ゲートウェイ)", "vi": "OpenAI-compatible (Gateway nội bộ)"},
"settings.group.anthropic": {"en": "Anthropic Claude", "ja": "Anthropic Claude", "vi": "Anthropic Claude"},
# Name for the language/tray block at the top of Settings — it had none,
# because until the index existed nothing had to refer to it.
"settings.group.general": {"en": "General", "ja": "一般", "vi": "Chung"},
"settings.group.provider": {"en": "AI Provider", "ja": "AI プロバイダー", "vi": "Nhà cung cấp AI"},
"settings.group.parameter": {"en": "Parameter", "ja": "Parameter", "vi": "Parameter"},
"settings.param_section_pricing": {
@@ -1751,6 +1799,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"vi": "Xóa connector \"{name}\"?"},
"ext.add_title": {"en": "Add connector", "ja": "コネクタを追加", "vi": "Thêm connector"},
"ext.edit_title": {"en": "Edit connector", "ja": "コネクタを編集", "vi": "Sửa connector"},
"ext.category_label": {"en": "Category", "ja": "カテゴリ", "vi": "Nhóm"},
"ext.preset_label": {"en": "App", "ja": "アプリ", "vi": "Ứng dụng"},
"ext.preset_custom": {"en": "(Custom…)", "ja": "(カスタム…)", "vi": "(Tuỳ chỉnh…)"},
"ext.name_label": {"en": "Display name", "ja": "表示名", "vi": "Tên hiển thị"},
@@ -2338,22 +2387,36 @@ STRINGS: Dict[str, Dict[str, str]] = {
# ---- monitoring_tab.py (📊 Monitoring Dashboard) --------------------
"monitoring.title": {"en": "Monitoring Dashboard", "ja": "モニタリングダッシュボード", "vi": "Bảng giám sát"},
"monitoring.refresh": {"en": "Refresh", "ja": "更新", "vi": "Làm mới"},
"monitoring.tab_security": {"en": "Security Events", "ja": "セキュリティイベント", "vi": "Sự kiện bảo mật"},
"monitoring.tab_mcp": {"en": "MCP Call History", "ja": "MCP 呼び出し履歴", "vi": "Lịch sử gọi MCP"},
"monitoring.tab_actions": {"en": "Action Logs", "ja": "アクションログ", "vi": "Nhật ký hành động"},
"monitoring.tab_agents": {"en": "Agent Status", "ja": "エージェント状態", "vi": "Trạng thái Agent"},
"monitoring.tab_security": {"en": "Security", "ja": "セキュリティ", "vi": "Bảo mật"},
"monitoring.tab_mcp": {"en": "MCP", "ja": "MCP", "vi": "MCP"},
"monitoring.tab_actions": {"en": "Actions", "ja": "アクション", "vi": "Hành động"},
"monitoring.tab_agents": {"en": "Agent", "ja": "エージェント", "vi": "Agent"},
"monitoring.tab_accounts": {"en": "Accounts", "ja": "アカウント", "vi": "Tài khoản"},
"monitoring.col_time": {"en": "Time", "ja": "時刻", "vi": "Thời gian"},
"monitoring.col_role": {"en": "Agent Role", "ja": "エージェント役割", "vi": "Vai trò Agent"},
"monitoring.col_name": {"en": "Action", "ja": "アクション", "vi": "Hành động"},
"monitoring.col_result": {"en": "Result", "ja": "結果", "vi": "Kết quả"},
# Security Events shows WHICH rule fired instead of a result that is always
# the same — every security_block is recorded with ok=False.
"monitoring.col_action": {"en": "Action", "ja": "アクション", "vi": "Hành động"},
# The fourth KPI tile on Overview, as the wireframe labels it.
"monitoring.overview_calls": {"en": "Calls", "ja": "呼び出し", "vi": "Lượt gọi"},
# The fold under the Sandbox summary line — the wireframe shows only the
# summary, so the ID / created / uptime / limits rows live behind this.
"monitoring.overview_disk_free": {
"en": "{size} free", "ja": "空き {size}", "vi": "{size} trống"},
"monitoring.overview_disk_label": {"en": "Disk", "ja": "ディスク", "vi": "Đĩa"},
"monitoring.overview_sbx_detail": {
"en": "Details", "ja": "詳細", "vi": "Chi tiết"},
"monitoring.col_detail": {"en": "Detail", "ja": "詳細", "vi": "Chi tiết"},
"monitoring.col_account": {"en": "Account", "ja": "アカウント", "vi": "Tài khoản"},
"monitoring.col_machine": {"en": "Machine", "ja": "マシン", "vi": "Máy"},
"monitoring.col_agent": {"en": "Agent", "ja": "エージェント", "vi": "Agent"},
"monitoring.col_active": {"en": "Active", "ja": "稼働中", "vi": "Đang chạy"},
"monitoring.col_source": {"en": "Source", "ja": "ソース", "vi": "Nguồn"},
"monitoring.active_n": {"en": "{n} running", "ja": "{n} 件実行中", "vi": "{n} đang chạy"},
"monitoring.idle": {"en": "Idle", "ja": "アイドル", "vi": "Rảnh"},
"monitoring.agent_status_title": {
"en": "Agent Status", "ja": "エージェント状態", "vi": "Trạng thái Agent"},
"monitoring.source_cowork": {
"en": "Cowork tab's active turns", "ja": "Cowork タブの実行中ターン",
"vi": "Lượt đang chạy của tab Cowork"},
@@ -2384,7 +2447,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
# ---- monitoring_tab.py — Overview card dashboard ---------------------
"monitoring.tab_overview": {"en": "Overview", "ja": "概要", "vi": "Tổng quan"},
"monitoring.overview_usage_title": {
"en": "Token Usage & Cost", "ja": "トークン使用量とコスト", "vi": "Sử dụng token & Chi phí"},
"en": "Token & Cost", "ja": "トークンとコスト", "vi": "Token & Chi phí"},
"monitoring.overview_currency": {"en": "Currency:", "ja": "通貨:", "vi": "Tiền tệ:"},
"monitoring.tab_agents_admin": {
"en": "Agents Admin", "ja": "エージェント管理", "vi": "Agents Admin"},
@@ -2418,8 +2481,8 @@ STRINGS: Dict[str, Dict[str, str]] = {
"下から独自のSVGアイコンを追加でき、名前ですぐ使えます。",
"vi": "Các icon dùng cho agent và flow. Gõ tên vào ô Icon của step/agent để dùng. Thêm icon SVG "
"của bạn ở dưới — dùng được ngay bằng tên."},
"icons_admin.search": {"en": "Search built-in icons…", "ja": "組込みアイコンを検索…", "vi": "Tìm icon có sẵn…"},
"icons_admin.builtin": {"en": "Built-in icons", "ja": "組込みアイコン", "vi": "Icon có sẵn"},
"icons_admin.search": {"en": "Search icons by name…", "ja": "名前でアイコンを検索…", "vi": "Tìm icon theo tên…"},
"icons_admin.builtin": {"en": "Built-in icons", "ja": "組込みアイコン", "vi": "Icon tích hợp"},
"icons_admin.custom": {"en": "Custom icons", "ja": "カスタムアイコン", "vi": "Icon tùy chỉnh"},
"icons_admin.add": {"en": "Add SVG file", "ja": "SVGファイルを追加", "vi": "Thêm tệp SVG"},
"icons_admin.paste": {"en": "Paste SVG", "ja": "SVGを貼付", "vi": "Dán SVG"},
@@ -2431,9 +2494,6 @@ STRINGS: Dict[str, Dict[str, str]] = {
"icons_admin.select_custom": {"en": "Select a custom icon to delete.",
"ja": "削除するカスタムアイコンを選択してください。",
"vi": "Hãy chọn một icon tùy chỉnh để xóa."},
"tools_admin.col_name": {"en": "Tool", "ja": "ツール", "vi": "Tool"},
"tools_admin.col_desc": {"en": "Description", "ja": "説明", "vi": "Mô tả"},
"tools_admin.col_enabled": {"en": "Enabled", "ja": "有効", "vi": "Bật"},
"tools_admin.jira_note": {
"en": "Jira connection setup moved to the Connector tab → set it up there; here you only turn "
"the jira_search / jira_get_issue tools on or off.",
@@ -2468,10 +2528,9 @@ STRINGS: Dict[str, Dict[str, str]] = {
"en": "Double-click to connect Jira (paste any Jira link — no per-request setup after that).",
"ja": "ダブルクリックで Jira に接続(Jira リンクを貼るだけ、以降は設定不要)。",
"vi": "Nhấp đúp để kết nối Jira (dán bất kỳ link Jira nào — sau đó không cần thiết lập gì thêm)."},
"connectors.dbl_configure": {
"en": "Double-click a connector to configure it.",
"ja": "コネクタをダブルクリックして設定します。",
"vi": "Nhấp đúp vào một connector để thiết lập."},
"connectors.builtin_auto": {
"en": "Built-in, connects automatically", "ja": "組み込み、自動接続",
"vi": "Tích hợp, tự kết nối"},
"connectors.connect_external": {
"en": "Connect to external connectors",
"ja": "外部コネクタに接続する",
@@ -2624,6 +2683,19 @@ STRINGS: Dict[str, Dict[str, str]] = {
"vi": "Chạy flow đã chọn ở nền — nhiều flow chạy song song"},
"co4e.running_flows": {"en": "Running flows", "ja": "実行中のフロー", "vi": "Flow đang chạy"},
"co4e.runs_tab": {"en": "Flow Status", "ja": "フロー状態", "vi": "Flow Status"},
# The flow tab strip was removed, so its pinned Flow Status tab became a
# toggle in the flow toolbar — and that page needs its own way back.
"co4e.tt_runs_tab": {
"en": "Show every flow run", "ja": "すべてのフロー実行を表示",
"vi": "Xem toàn bộ lần chạy flow"},
"co4e.back_to_flow": {"en": "Back to flow", "ja": "フローに戻る", "vi": "Về flow"},
"co4e.new_flow_ready": {
"en": "New flow — type a name, then drag agents onto the canvas",
"ja": "新しいフロー — 名前を入力し、エージェントをキャンバスへ",
"vi": "Flow mới — đặt tên rồi kéo agent vào canvas"},
"co4e.tt_back_to_flow": {
"en": "Back to the flow editor", "ja": "フローエディタに戻る",
"vi": "Quay lại màn dựng flow"},
"co4e.runs_tab_n": {"en": "Flow Status ({n})", "ja": "フロー状態 ({n})", "vi": "Flow Status ({n})"},
"co4e.runs_col_flow": {"en": "Flow", "ja": "フロー", "vi": "Flow"},
"co4e.runs_col_status": {"en": "Status", "ja": "状態", "vi": "Trạng thái"},
@@ -2693,6 +2765,9 @@ STRINGS: Dict[str, Dict[str, str]] = {
"ja": "フローとチャット — /agent:<name> または /skill:<name>",
"vi": "Chat với flow — dùng /agent:<name> hoặc /skill:<name>"},
"co4e.send": {"en": "Send", "ja": "送信", "vi": "Gửi"},
"co4e.tab_basic": {"en": "Basic", "ja": "基本", "vi": "Cơ bản"},
"co4e.tab_model_perm": {"en": "Model & Permission", "ja": "モデルと権限", "vi": "Model & Quyền"},
"co4e.tab_skills_files": {"en": "Skills & Files", "ja": "スキルとファイル", "vi": "Skills & Tệp"},
"co4e.f_label": {"en": "Label", "ja": "ラベル", "vi": "Nhãn"},
"co4e.f_role": {"en": "Role", "ja": "ロール", "vi": "Vai trò"},
"co4e.f_name": {"en": "Name", "ja": "名前", "vi": "Tên"},
@@ -2743,6 +2818,9 @@ STRINGS: Dict[str, Dict[str, str]] = {
"vi": "Không tìm thấy agent '{name}'."},
# ---- agents_admin_tab.py — Admin-only agent catalog -------------------
"agents_admin.page_title": {"en": "Agents Admin", "ja": "Agents Admin", "vi": "Agents Admin"},
"agents_admin.edit_row_tooltip": {"en": "Edit", "ja": "編集", "vi": "Sửa"},
"agents_admin.delete_row_tooltip": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
"agents_admin.hint": {
"en": "System-management agents shared across every machine (stored in the shared accounts folder): the help agent and Schedule Task executors. These are NOT the agents you pick in Cowork or Co4E.",
"ja": "全マシンで共有されるシステム管理用エージェント(共有フォルダーに保存):ヘルプエージェントやスケジュールタスクの実行エージェントなど。CoworkやCo4Eで選択するエージェントではありません。",
@@ -2754,8 +2832,6 @@ STRINGS: Dict[str, Dict[str, str]] = {
"en": "Delete agent \"{name}\"?", "ja": "エージェント「{name}」を削除しますか?",
"vi": "Xóa agent \"{name}\"?"},
"agents_admin.add_btn": {"en": "Add", "ja": "追加", "vi": "Thêm"},
"agents_admin.edit_btn": {"en": "Edit", "ja": "編集", "vi": "Sửa"},
"agents_admin.delete_btn": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
"agents_admin.f_name": {"en": "Name", "ja": "名前", "vi": "Tên"},
"agents_admin.f_kind": {"en": "App function", "ja": "アプリ機能", "vi": "Chức năng App"},
"agents_admin.f_prompt": {"en": "Instructions", "ja": "指示", "vi": "Chỉ dẫn"},
@@ -2787,7 +2863,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"agents_admin.col_enabled": {"en": "Enabled", "ja": "有効", "vi": "Kích hoạt"},
"agents_admin.col_status": {"en": "Status", "ja": "状態", "vi": "Trạng thái"},
"agents_admin.col_updated": {"en": "Updated", "ja": "更新", "vi": "Cập nhật"},
"agents_admin.check_btn": {"en": "Check", "ja": "チェック", "vi": "Kiểm tra"},
"agents_admin.check_btn": {"en": "Check all", "ja": "すべてチェック", "vi": "Kiểm tra tất cả"},
"agents_admin.check_tooltip": {
"en": "Check each agent's effective provider/model connectivity",
"ja": "各エージェントの実効プロバイダ/モデルの接続性を確認",
@@ -2835,12 +2911,67 @@ STRINGS: Dict[str, Dict[str, str]] = {
"en": "AI turns your question into a filter keyword (e.g. \"which commands failed today?\").",
"ja": "質問をAIがフィルターキーワードに変換します。",
"vi": "AI chuyển câu hỏi thành từ khóa lọc (vd: \"hôm nay lệnh nào bị lỗi?\")."},
"monitoring.security_detail_title": {
"en": "Event details", "ja": "イベント詳細", "vi": "Chi tiết sự kiện"},
"monitoring.security_detail_close": {
"en": "Close", "ja": "閉じる", "vi": "Đóng"},
"monitoring.security_events_title": {
"en": "Security Events", "ja": "セキュリティイベント", "vi": "Sự kiện bảo mật"},
"monitoring.mcp_history_title": {
"en": "MCP Call History", "ja": "MCP 呼び出し履歴", "vi": "Lịch sử gọi MCP"},
"monitoring.action_logs_title": {
"en": "Action Logs", "ja": "アクションログ", "vi": "Nhật ký hành động"},
"monitoring.col_detail_block": {
"en": "Block detail", "ja": "ブロック詳細", "vi": "Chi tiết chặn"},
# ---- event-detail panel (ui-audit_v2.html openDetail()) --------------
"monitoring.detail_section_general": {
"en": "General info", "ja": "基本情報", "vi": "Thông tin chung"},
"monitoring.detail_section_action": {
"en": "Action", "ja": "アクション", "vi": "Hành động"},
"monitoring.detail_section_metadata": {
"en": "Metadata", "ja": "メタデータ", "vi": "Metadata"},
"monitoring.detail_type": {"en": "Type", "ja": "種類", "vi": "Loại"},
"monitoring.detail_status": {"en": "Status", "ja": "ステータス", "vi": "Trạng thái"},
"monitoring.detail_event_id": {"en": "Event ID", "ja": "イベントID", "vi": "Event ID"},
"monitoring.detail_policy": {"en": "Policy", "ja": "ポリシー", "vi": "Policy"},
"monitoring.detail_severity": {"en": "Severity", "ja": "重大度", "vi": "Severity"},
"monitoring.detail_copy": {"en": "Copy", "ja": "コピー", "vi": "Copy"},
"monitoring.detail_copied": {"en": "Copied", "ja": "コピー済み", "vi": "Đã copy"},
# Trạng thái pill — which rule fired, phrased as the enforcement outcome
# (distinct wording from the Loại/action_* labels below, matching
# ui-audit_v2.html's statusInfo() vs actionLabel).
"monitoring.status_blocked": {"en": "Blocked", "ja": "ブロック済み", "vi": "Đã chặn"},
"monitoring.status_path": {"en": "Path blocked", "ja": "パスをブロック", "vi": "Path chặn"},
"monitoring.status_network": {"en": "Network blocked", "ja": "ネットワークをブロック", "vi": "Mạng chặn"},
"monitoring.status_secret": {"en": "Secret leaked", "ja": "シークレット漏洩", "vi": "Bí mật lộ"},
"monitoring.status_ok": {"en": "Succeeded", "ja": "成功", "vi": "Thành công"},
"monitoring.status_failed": {"en": "Failed", "ja": "失敗", "vi": "Thất bại"},
"monitoring.severity_critical": {"en": "CRITICAL", "ja": "CRITICAL", "vi": "CRITICAL"},
"monitoring.severity_medium": {"en": "MEDIUM", "ja": "MEDIUM", "vi": "MEDIUM"},
"monitoring.severity_info": {"en": "INFO", "ja": "INFO", "vi": "INFO"},
# Loại field — a human label for the raw event name (audit_log ``name``).
"monitoring.action_prompt": {"en": "Risky prompt", "ja": "危険なプロンプト", "vi": "Prompt rủi ro"},
"monitoring.action_dangerous_command": {
"en": "Dangerous command", "ja": "危険なコマンド", "vi": "Lệnh nguy hiểm"},
"monitoring.action_install_package": {
"en": "Package install", "ja": "パッケージインストール", "vi": "Cài đặt gói"},
"monitoring.action_path_outside_sandbox": {
"en": "Path outside sandbox", "ja": "サンドボックス外のパス", "vi": "Path ngoài sandbox"},
"monitoring.action_network_blocked": {
"en": "Network blocked", "ja": "ネットワークブロック", "vi": "Mạng bị chặn"},
"monitoring.action_secret_in_output": {
"en": "Secret disclosed", "ja": "シークレット漏洩", "vi": "Tiết lộ bí mật"},
"monitoring.overview_activity_title": {
"en": "Recent Activity", "ja": "最近のアクティビティ", "vi": "Hoạt động gần đây"},
"en": "Recent log", "ja": "最近のログ", "vi": "Nhật ký gần đây"},
"monitoring.overview_no_activity": {
"en": "No activity yet.", "ja": "まだアクティビティはありません。", "vi": "Chưa có hoạt động nào."},
"monitoring.overview_resource_title": {
"en": "Resource Usage", "ja": "リソース使用状況", "vi": "Sử dụng tài nguyên"},
"en": "Resources", "ja": "リソース", "vi": "Tài nguyên"},
"monitoring.overview_res_cpu": {"en": "CPU", "ja": "CPU", "vi": "CPU"},
"monitoring.overview_res_mem": {"en": "Memory", "ja": "メモリ", "vi": "Bộ nhớ"},
"monitoring.overview_res_disk": {"en": "Disk I/O", "ja": "ディスク I/O", "vi": "Disk I/O"},
@@ -2867,7 +2998,9 @@ STRINGS: Dict[str, Dict[str, str]] = {
"monitoring.pricing_col_input": {"en": "Input price", "ja": "入力単価", "vi": "Giá input"},
"monitoring.pricing_col_output": {"en": "Output price", "ja": "出力単価", "vi": "Giá output"},
"monitoring.overview_sandbox_details_title": {
"en": "Sandbox Details", "ja": "サンドボックス詳細", "vi": "Chi tiết Sandbox"},
# One section now, holding both the sandbox facts and the permissions.
"en": "Sandbox & Permissions", "ja": "サンドボックスと権限",
"vi": "Sandbox & Quyền"},
"monitoring.overview_sandbox_id": {"en": "Sandbox ID", "ja": "サンドボックス ID", "vi": "Sandbox ID"},
"monitoring.overview_status": {"en": "Status", "ja": "状態", "vi": "Trạng thái"},
"monitoring.overview_status_running": {"en": "Running", "ja": "実行中", "vi": "Đang chạy"},
+7
View File
@@ -0,0 +1,7 @@
"""Infrastructure layer - adapters to the outside world.
Concrete implementations of what the inner layers only describe: HTTP calls to
model gateways, the OS keyring, the filesystem, subprocesses, telemetry sinks.
May import ``domain/`` (to speak its types) and third-party libraries, but never
``presentation/``/``ui/``.
"""
+5
View File
@@ -0,0 +1,5 @@
"""Provider adapters and the central provider catalogue (EPIC R03)."""
from .provider_registry import ProviderRegistry, default_registry
__all__ = ["ProviderRegistry", "default_registry"]
@@ -0,0 +1,207 @@
"""ProviderRegistry - the one place a provider is declared (R03-T02).
Replaces the three-way split between ``providers/factory.py::_REGISTRY``,
``config.py::DEFAULT_CONFIG["providers"]`` and ``config.py::PROVIDER_LABELS``
with a single catalogue of :class:`ProviderDescriptor` objects plus the
implementation class each one maps to.
Adding a provider is now one entry in :data:`BUILT_IN_PROVIDERS` (declarative
facts) and one line in :data:`_IMPLEMENTATIONS` (which class speaks that
protocol) - see ``docs/governance/contributor-recipes.md`` (R10-T04).
Migration note (strangler fig, ADR-001 section 4): this registry does not
re-implement any provider. It builds the SAME classes ``providers/factory.py``
builds, so both entry points stay behaviourally identical while call sites move
over one at a time.
"""
from __future__ import annotations
from typing import Any, Dict, Iterable, List, Mapping, Optional
from cowork_local.domain.models.provider_descriptor import (
ProviderCapability,
ProviderDescriptor,
)
from cowork_local.providers.base import Provider, ProviderError
_CAP = ProviderCapability
# Every provider the app ships with, described once.
#
# The capability sets are deliberately conservative: a capability listed here is
# one the adapter genuinely implements today. Claiming VISION for a provider
# whose chat() cannot translate an image block would route an image turn into a
# guaranteed failure, so an unimplemented capability must stay off the list.
BUILT_IN_PROVIDERS: tuple = (
ProviderDescriptor(
id="openai_compat",
label="OpenAI-compatible (Internal Gateway)",
protocol="openai_compat",
default_model="gpt-4o-mini",
capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.VISION,
_CAP.REASONING, _CAP.MODEL_LISTING}),
notes="Any endpoint speaking the OpenAI Chat Completions protocol.",
),
ProviderDescriptor(
id="anthropic",
label="Anthropic Claude",
protocol="anthropic",
default_model="claude-sonnet-4-6",
capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.VISION,
_CAP.MODEL_LISTING}),
),
ProviderDescriptor(
id="ollama",
label="Ollama (local models)",
protocol="openai_compat",
default_model="llama3.1",
capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.REASONING,
_CAP.MODEL_LISTING}),
# Ollama ignores the key, but the OpenAI client layer requires a value,
# so the default config ships a placeholder rather than an empty string.
requires_api_key=False,
local=True,
notes="Runs on this machine - no data leaves the device, no token cost.",
),
ProviderDescriptor(
id="github_copilot",
label="GitHub Copilot",
protocol="openai_compat",
default_model="gpt-4o",
capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.MODEL_LISTING}),
notes="Paste a Copilot token as the API key.",
),
ProviderDescriptor(
id="codex",
label="OpenAI (Codex / GPT)",
protocol="openai_compat",
default_model="gpt-4o-mini",
capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.VISION,
_CAP.REASONING, _CAP.MODEL_LISTING}),
),
)
def _implementations() -> Dict[str, type]:
"""Protocol -> adapter class.
Imported lazily inside the function because ``providers/anthropic.py`` and
``providers/openai_compat.py`` pull in ``requests`` at import time; keeping
that out of module import means a test that only inspects descriptors pays
no import cost at all.
"""
from cowork_local.providers.anthropic import AnthropicProvider
from cowork_local.providers.openai_compat import OpenAICompatProvider
return {
"openai_compat": OpenAICompatProvider,
"anthropic": AnthropicProvider,
}
class ProviderRegistry:
"""Catalogue of known providers + the factory that instantiates them.
Intentionally holds no config and no app context: it is a pure lookup table
plus a build step, so it can be constructed in a test with a custom
descriptor list and no application running.
"""
def __init__(self, descriptors: Optional[Iterable[ProviderDescriptor]] = None) -> None:
# Dict preserves declaration order (Python 3.7+), which is the order
# Settings lists providers in - so the catalogue order is data, not luck.
self._by_id: Dict[str, ProviderDescriptor] = {
d.id: d for d in (descriptors if descriptors is not None else BUILT_IN_PROVIDERS)
}
# -- catalogue queries ------------------------------------------------ #
def ids(self) -> List[str]:
"""Known provider ids, in declaration order."""
return list(self._by_id)
def all(self) -> List[ProviderDescriptor]:
"""Every descriptor, in declaration order."""
return list(self._by_id.values())
def get(self, provider_id: str) -> Optional[ProviderDescriptor]:
"""The descriptor for ``provider_id``, or None when unknown.
Returns None rather than raising because the caller is often reacting to
a config file that may name a provider from a newer version; the UI
should be able to skip it, not crash.
"""
return self._by_id.get(provider_id)
def require(self, provider_id: str) -> ProviderDescriptor:
"""Like :meth:`get` but raises :class:`ProviderError` when unknown.
Same error type ``providers/factory.py::build_provider`` already raises,
so callers that migrate to the registry keep their existing except clause.
"""
descriptor = self._by_id.get(provider_id)
if descriptor is None:
known = ", ".join(self._by_id) or "(none)"
raise ProviderError(f"Unsupported provider: {provider_id} (known: {known})")
return descriptor
def labels(self) -> Dict[str, str]:
"""``{id: label}`` - the drop-in replacement for ``config.PROVIDER_LABELS``."""
return {d.id: d.label for d in self._by_id.values()}
def supporting(self, capability: ProviderCapability) -> List[ProviderDescriptor]:
"""Every descriptor advertising ``capability`` - used to answer "which
providers could serve this turn?" before any of them is built."""
return [d for d in self._by_id.values() if d.supports(capability)]
def configured(self, providers_conf: Mapping[str, Mapping[str, Any]]
) -> List[ProviderDescriptor]:
"""Descriptors whose config section is complete enough to actually call.
``providers_conf`` is ``AppConfig.data["providers"]``. Passing the raw
mapping (not the AppConfig object) keeps this layer independent of the
config implementation, which EPIC R02 is rewriting in parallel.
"""
return [d for d in self._by_id.values()
if d.is_configured(providers_conf.get(d.id, {}) or {})]
# -- construction ----------------------------------------------------- #
def build(self, provider_id: str, conf: Mapping[str, Any],
model: str = "") -> Provider:
"""Instantiate the adapter for ``provider_id``.
``model`` overrides the configured model for this instance only - that is
how the routing layer runs one turn on a different model without mutating
the user's saved settings.
"""
descriptor = self.require(provider_id)
impl = _implementations().get(descriptor.protocol)
if impl is None: # pragma: no cover - only reachable via a bad descriptor
raise ProviderError(
f"Provider '{provider_id}' declares unknown protocol "
f"'{descriptor.protocol}'."
)
# Copy before mutating: conf is the caller's live config dict, and
# writing the routed model into it would silently change the user's
# saved default for every later turn.
resolved = dict(conf or {})
resolved["model"] = descriptor.resolve_model(conf, model)
instance = impl(resolved)
# The adapter class is shared by several ids (three of them are
# OpenAI-compatible), so its class-level `name` cannot identify which
# provider this is. Stamping the instance keeps usage records, audit
# entries and routing candidate keys attributed to the right provider.
instance.name = descriptor.id
return instance
def describe(self, provider_id: str, conf: Optional[Mapping[str, Any]] = None) -> str:
"""One-line description used in logs and error messages."""
return self.require(provider_id).describe(conf)
# Shared default instance. Callers that need the built-in catalogue use this
# instead of constructing a registry each time; tests build their own with an
# explicit descriptor list.
default_registry = ProviderRegistry()
__all__ = ["ProviderRegistry", "BUILT_IN_PROVIDERS", "default_registry"]
+21
View File
@@ -0,0 +1,21 @@
"""Telemetry sinks: where token usage and turn metrics are recorded (EPIC R03)."""
from .usage_sink import (
NullUsageSink,
RecordingUsageSink,
UsageEvent,
UsageEventSink,
UsageTrackerSink,
default_sink,
set_default_sink,
)
__all__ = [
"UsageEvent",
"UsageEventSink",
"UsageTrackerSink",
"NullUsageSink",
"RecordingUsageSink",
"default_sink",
"set_default_sink",
]
+229
View File
@@ -0,0 +1,229 @@
"""UsageEventSink - where a turn's token usage goes (R03-T06).
Today each provider records its own usage inline, in the middle of the streaming
loop::
# providers/openai_compat.py
def _record_usage(self, messages, text_parts, tool_acc, usage_seen):
from ..core import usage_tracker as ut
...
ut.record(self.name, self.model, ...)
Three problems with that shape:
1. **Hidden side effect.** ``chat()`` looks like a pure request/response call but
also writes to the Dashboard's store, so a test of a provider silently
appends rows to the developer's real usage history.
2. **Duplicated estimation.** The "no usage block from the server, so estimate
at ~4 chars/token" fallback is copy-pasted per provider and can drift.
3. **One hard-wired destination.** Usage can only ever go to
``core.usage_tracker``; a run that wants to bill a workflow, or a test that
wants to assert on token counts, has nowhere to plug in.
This module introduces the seam: providers build a :class:`UsageEvent` and hand
it to a :class:`UsageEventSink`. Production wires :class:`UsageTrackerSink`
(same destination, same numbers as before); tests wire
:class:`RecordingUsageSink` or :class:`NullUsageSink`.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Protocol, Sequence
logger = logging.getLogger("cowork_local.telemetry")
# Rough characters-per-token ratio used when the gateway sends no usage block.
# Matches the constant behaviour of ``core.usage_tracker.estimate_tokens`` so
# moving the estimation here does not change a single recorded number.
_CHARS_PER_TOKEN = 4
@dataclass(frozen=True)
class UsageEvent:
"""Token usage for exactly one provider round trip.
``estimated`` marks a record derived from text length rather than reported by
the server. The Dashboard shows the two differently, and conflating them
would make cost figures look more precise than they are.
"""
provider: str
model: str
input_tokens: int = 0
output_tokens: int = 0
cached_tokens: int = 0
estimated: bool = False
@property
def total_tokens(self) -> int:
"""Input + output. Cached tokens are a subset of input, not an addition,
so adding them here would double-count a cache hit."""
return self.input_tokens + self.output_tokens
def to_dict(self) -> Dict[str, Any]:
"""JSON-safe projection for logs and for sinks that persist raw events."""
return {
"provider": self.provider,
"model": self.model,
"input_tokens": self.input_tokens,
"output_tokens": self.output_tokens,
"cached_tokens": self.cached_tokens,
"estimated": self.estimated,
}
class UsageEventSink(Protocol):
"""Anything that can absorb a :class:`UsageEvent`.
Implementations MUST NOT raise: telemetry is observability, and a failure to
record usage must never abort the turn that produced it.
"""
def record(self, event: UsageEvent) -> None:
"""Absorb one usage event."""
class NullUsageSink:
"""Discards everything. The default for tests and headless tooling, so a
unit test never writes into the developer's real usage history."""
def record(self, event: UsageEvent) -> None: # noqa: D102 - see protocol
return None
class RecordingUsageSink:
"""Keeps events in memory so a test can assert on what was recorded."""
def __init__(self) -> None:
self.events: List[UsageEvent] = []
def record(self, event: UsageEvent) -> None: # noqa: D102 - see protocol
self.events.append(event)
@property
def total_tokens(self) -> int:
"""Sum across every recorded event."""
return sum(e.total_tokens for e in self.events)
class UsageTrackerSink:
"""Forwards to ``core.usage_tracker`` - the Dashboard's store.
This is the production sink and the only place that still knows about the
legacy tracker module, which is what lets EPIC R10 replace the storage
without touching a single provider.
"""
def __init__(self, tracker: Optional[Any] = None) -> None:
# Injectable for tests; imported lazily otherwise because the tracker
# touches the config directory at import time.
self._tracker = tracker
def _resolve(self) -> Any:
if self._tracker is None:
from cowork_local.core import usage_tracker
self._tracker = usage_tracker
return self._tracker
def record(self, event: UsageEvent) -> None:
"""Write the event to the usage tracker, swallowing any failure.
The bare except mirrors the behaviour this replaces (each provider
already wrapped its ``ut.record`` call in ``try/except: pass``) but logs
at debug level instead of discarding the reason entirely, so a broken
Dashboard store can at least be diagnosed.
"""
try:
self._resolve().record(
event.provider, event.model,
event.input_tokens, event.output_tokens, event.cached_tokens,
estimated=event.estimated,
)
except Exception: # noqa: BLE001 - telemetry must never break a turn
logger.debug("usage sink: failed to record %s", event.to_dict(), exc_info=True)
def estimate_tokens(text: str) -> int:
"""Approximate token count for ``text`` (~4 characters per token).
Deliberately identical to ``core.usage_tracker.estimate_tokens`` so that
moving estimation into this layer changes no recorded number. Duplicated
rather than imported to keep this module free of the legacy dependency;
:class:`UsageTrackerSink` is the only bridge back to it.
"""
return max(0, len(text or "") // _CHARS_PER_TOKEN)
def estimated_event(provider: str, model: str, sent: str, received: str) -> UsageEvent:
"""Build an estimated :class:`UsageEvent` from the raw text of a round trip.
Used when the gateway sends no usage block - most self-hosted OpenAI-compatible
servers and Ollama do not.
"""
return UsageEvent(
provider=provider, model=model,
input_tokens=estimate_tokens(sent),
output_tokens=estimate_tokens(received),
cached_tokens=0,
estimated=True,
)
def openai_usage_event(provider: str, model: str, usage: Dict[str, Any]) -> UsageEvent:
"""Build a reported :class:`UsageEvent` from an OpenAI-style usage block."""
details = usage.get("prompt_tokens_details") or {}
return UsageEvent(
provider=provider, model=model,
input_tokens=int(usage.get("prompt_tokens", 0) or 0),
output_tokens=int(usage.get("completion_tokens", 0) or 0),
cached_tokens=int(details.get("cached_tokens", 0) or 0),
estimated=False,
)
def anthropic_usage_event(provider: str, model: str, usage: Dict[str, Any]) -> UsageEvent:
"""Build a reported :class:`UsageEvent` from Anthropic's usage accumulator.
Anthropic reports input tokens on ``message_start`` and output tokens on
``message_delta``, so ``providers/anthropic.py`` accumulates them into a dict
keyed ``in``/``out``/``cache`` - this reads that shape.
"""
return UsageEvent(
provider=provider, model=model,
input_tokens=int(usage.get("in", 0) or 0),
output_tokens=int(usage.get("out", 0) or 0),
cached_tokens=int(usage.get("cache", 0) or 0),
estimated=False,
)
# The sink providers use unless one is injected. A module-level default keeps
# the change to the provider classes to a single attribute, and lets a test swap
# the destination process-wide with one monkeypatch.
default_sink: UsageEventSink = UsageTrackerSink()
def set_default_sink(sink: UsageEventSink) -> UsageEventSink:
"""Replace the process-wide default sink; returns the previous one so a
caller (or fixture) can restore it."""
global default_sink
previous = default_sink
default_sink = sink
return previous
__all__ = [
"UsageEvent",
"UsageEventSink",
"UsageTrackerSink",
"NullUsageSink",
"RecordingUsageSink",
"estimate_tokens",
"estimated_event",
"openai_usage_event",
"anthropic_usage_event",
"default_sink",
"set_default_sink",
]
+23
View File
@@ -0,0 +1,23 @@
services:
cowork-desktop:
image: python:3.11-slim-bookworm
container_name: cowork-local-desktop-preview
working_dir: /workspace
volumes:
- /workspace:/workspace:Z
- pip-cache:/root/.cache/pip:Z
ports:
- "6080:6080"
environment:
PYTHONUNBUFFERED: "1"
QT_QPA_PLATFORM: "vnc:size=1280x800:depth=32"
QT_QPA_VNC_HOST: "127.0.0.1"
QT_QPA_VNC_PORT: "5900"
QSG_RHI_BACKEND: "software"
PYTHONPATH: "/opt"
entrypoint: ["/bin/sh", "/workspace/.vibeflow-preview/entrypoint.sh"]
command: []
restart: unless-stopped
volumes:
pip-cache: {}
View File
+12 -14
View File
@@ -292,21 +292,19 @@ class AnthropicProvider(Provider):
args = {"_raw": b["json"]}
tool_calls.append({"id": b["id"], "name": b["name"], "arguments": args})
# Dashboard usage event — real counts from the stream's usage events,
# else a ~4 chars/token estimate. Never breaks the turn.
try:
from ..core import usage_tracker as ut
# Dashboard usage event — real counts from the stream's usage events
# (input arrives on message_start, output on message_delta), else a
# ~4 chars/token estimate. Delivery is the sink's job (R03-T06), so this
# only translates Anthropic's wire shape into a canonical UsageEvent.
from ..infrastructure.telemetry import usage_sink as telemetry
if usage_seen:
ut.record(self.name, self.model, usage_seen.get("in", 0),
usage_seen.get("out", 0), usage_seen.get("cache", 0))
else:
sent = json.dumps(payload.get("messages", []), ensure_ascii=False)
got = "".join(text_parts) + "".join(b["json"] for b in blocks.values())
ut.record(self.name, self.model, ut.estimate_tokens(sent),
ut.estimate_tokens(got), 0, estimated=True)
except Exception: # noqa: BLE001
pass
if usage_seen:
event = telemetry.anthropic_usage_event(self.name, self.model, usage_seen)
else:
sent = json.dumps(payload.get("messages", []), ensure_ascii=False)
got = "".join(text_parts) + "".join(b["json"] for b in blocks.values())
event = telemetry.estimated_event(self.name, self.model, sent, got)
self._emit_usage(event)
return {"role": "assistant", "content": "".join(text_parts), "tool_calls": tool_calls}
+24
View File
@@ -224,6 +224,12 @@ class Provider:
# silently swallowing the error — Settings' "Test connection" / "Load
# models" surfaces this so "model won't load" has a concrete reason.
self.last_error = ""
# Where this provider's token usage goes (R03-T06). None means "the
# process-wide default sink", resolved lazily in _emit_usage so that a
# test can swap the destination without rebuilding every provider.
# Set it per instance to bill one run somewhere else (a workflow, a
# scheduled task) without touching global state.
self.usage_sink = None
def chat(
self,
@@ -274,6 +280,24 @@ class Provider:
return True, f"OK — {len(models)} model(s) available."
return False, "No models returned. Check base_url/API key and network access."
# -- telemetry -----------------------------------------------------
def _emit_usage(self, event) -> None:
"""Hand one ``UsageEvent`` to this provider's usage sink.
Never raises: recording how many tokens a turn cost must not be able to
fail the turn itself. Falls back to the process-wide default sink so
existing call sites keep reporting to the Dashboard exactly as before
(see infrastructure/telemetry/usage_sink.py)."""
try:
sink = self.usage_sink
if sink is None:
from ..infrastructure.telemetry import usage_sink as telemetry
sink = telemetry.default_sink
sink.record(event)
except Exception: # noqa: BLE001 — telemetry is never worth a failed turn
pass
# -- shared helpers ------------------------------------------------
@staticmethod
def _is_cancelled(cancel) -> bool:
+18 -15
View File
@@ -268,22 +268,25 @@ class OpenAICompatProvider(Provider):
def _record_usage(self, messages, text_parts, tool_acc, usage_seen) -> None:
"""One Dashboard usage event per turn: real counts when the server's
final chunk carried a "usage" block, a ~4 chars/token estimate
otherwise. Never breaks the turn."""
try:
from ..core import usage_tracker as ut
otherwise.
if usage_seen:
ut.record(self.name, self.model,
usage_seen.get("prompt_tokens", 0),
usage_seen.get("completion_tokens", 0),
(usage_seen.get("prompt_tokens_details") or {}).get("cached_tokens", 0))
else:
sent = json.dumps(self._to_api_messages(messages), ensure_ascii=False)
got = "".join(text_parts) + "".join(s["args"] for s in tool_acc.values())
ut.record(self.name, self.model, ut.estimate_tokens(sent),
ut.estimate_tokens(got), 0, estimated=True)
except Exception: # noqa: BLE001
pass
Building the event and delivering it are now separate concerns (R03-T06):
this method only translates THIS provider's wire shape into a canonical
``UsageEvent``; where it ends up is the sink's decision, so a test can
assert on token counts without writing to the real Dashboard store."""
from ..infrastructure.telemetry import usage_sink as telemetry
if usage_seen:
event = telemetry.openai_usage_event(self.name, self.model, usage_seen)
else:
# No usage block from the gateway (self-hosted servers and Ollama
# never send one) - fall back to estimating from the raw text of
# both directions, tool-call arguments included since the model was
# billed for generating them.
sent = json.dumps(self._to_api_messages(messages), ensure_ascii=False)
got = "".join(text_parts) + "".join(s["args"] for s in tool_acc.values())
event = telemetry.estimated_event(self.name, self.model, sent, got)
self._emit_usage(event)
def list_models(self):
self.last_error = ""
+9
View File
@@ -0,0 +1,9 @@
PySide6>=6.6
pydantic>=2
requests
psutil
pygments
openpyxl
python-pptx
networkx
pytest
+24
View File
@@ -0,0 +1,24 @@
# Cowork-Local BamBOO — dependencies
# Install: pip install -r requirements.txt
# --- Core UI framework ---
PySide6>=6.6.0
# --- HTTP client ---
requests>=2.31.0
# --- Process & resource monitoring ---
psutil>=5.9.0
# --- Document handling ---
openpyxl>=3.1.0 # Excel (.xlsx) creation & reading
python-pptx>=0.6.0 # PowerPoint (.pptx) editing
pypdf>=4.0.0 # PDF text extraction (preferred)
# PyPDF2>=3.0.0 # PDF fallback (optional, pypdf preferred)
# --- Microsoft 365 integration ---
msal>=1.24.0 # OAuth device-code flow for MS365
keyring>=24.0.0 # OS credential store (token cache)
# --- MCP (Model Context Protocol) ---
mcp>=1.0.0 # MCP client SDK (stdio transport)

Some files were not shown because too many files have changed in this diff Show More