CI / test (push) Canceled after 0s
## Summary Nhánh `feature/perf-ui-logic`: tối ưu hiệu năng/UI, sửa lỗi workspace và điều hướng, và làm cho công tắc **"Block network for agent-run commands"** chặn thật mọi đường ra mạng của app, **trừ nhà cung cấp AI**. **Chặn mạng (b78d483,8c497cf,10b8379)** - Bộ kiểm tra chung `application/network/network_guard.py`, nối vào cấu hình đang chạy ở Composition Root: đổi công tắc trong Settings là có hiệu lực ngay. - Lệnh shell của agent và task script chạy trong **Windows AppContainer không có quyền mạng**: kernel chặn socket, ping, DNS, Invoke-WebRequest… Không cần quyền admin. Không cô lập được thì lệnh bị từ chối, không chạy khi mạng còn mở. macOS dùng `sandbox-exec`, Linux dùng `unshare --net`. - Bật chặn thì: dừng MCP đang chạy, không khởi động server mới, từ chối lời gọi connector; Microsoft 365 (đăng nhập, Graph, đồng bộ cloud, rules, mail), Teams, nút Test REST/Jira/MCP, link đính kèm task, pip tự cài và tài nguyên web trong xem trước HTML đều bị từ chối. - Vẫn dùng được: chat, tải danh sách model, thử model; tool OneDrive đã đồng bộ trên máy. - Công tắc **mặc định tắt** khi mở app lần đầu; nhãn giữ nguyên như cũ. - Xem trước HTML trong tab Folder giờ hiện được ảnh/CSS/JS từ web khi mạng mở (trước đây trang `file://` không tải được). - Sửa lỗi app văng khi chuyển tab Graph → Folder: profile WebEngine của trang xem trước bị huỷ trước trang (`0xc0000409` trong Qt6Core.dll); giờ dùng một profile chung thuộc QApplication. - Không cấp quyền AppContainer kế thừa lên thư mục chứa PySide6 (nếu có, Chromium không nạp được `Qt6WebEngineCore.dll` và tab Graph trắng). - Cột mục lục trong Settings tính độ rộng theo kiểu chữ của mục đang chọn, "Sandbox Security Layer" không còn bị cắt. **Các commit khác trong nhánh** - `b7a41b3` mỗi thư mục làm việc chỉ thuộc về một project · `bbdf146` bật nút Sửa project khi đã có project đang mở - `35f24e0`, `cc8d5c8`, `2e3e719`, `c699beb` canh hàng / khoảng cách thanh điều hướng - `2759ed9` không refresh workspace khi chuyển tab Cowork · `7607f44` checkpoint hiệu năng và UI - `8548c1e` chặn tool mạng của agent · `caf3b74` renderer GraphRAG native trên macOS · `c00b83c` khoảng cách metadata hàng project · `a04f8a9` ẩn picker workspace cloud ## Change Type - [x] Cowork feature - [x] Bug fix - [ ] Core AI contribution - [x] Test / hardening - [x] 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? - Mọi đường ra mạng do app tự mở, trừ nhà cung cấp AI (xem Summary). - Test: `tests/test_network_guard_lanes.py` (có bài chạy AppContainer thật trên Windows), `tests/ui/test_html_preview_remote_images.py`. What is intentionally NOT included? - Chặn cả nhà cung cấp AI / chạy model trên máy (Phương án 2). - Terminal người dùng tự gõ trong tab Folder, sinh ảnh, cơ chế tự tin chứng chỉ lạ (`tls_trust`). - Huy hiệu trạng thái "đang chặn" trên thanh trên cùng. ## Validation - [x] Unit tests - [x] Integration tests - [x] Manual verification - [x] Regression check Commands / evidence: - `python -m pytest tests/test_network_guard_lanes.py tests/test_sandbox_block_network.py tests/ui -q` → chỉ còn 1 bài fail, fail cả trên `b7a41b3` (nhãn `ProjectRow` 'Project' chưa dịch, `tests/ui/test_i18n_khong_con_chu_cu.py`). - `python -m pytest tests -q --ignore=tests/ui` → 4 bài fail, cả 4 cũng fail trên `b7a41b3` (`test_canonical_audit_logger`, 2 bài `test_mcp_audit_security`, `test_monitoring_tab_container`). - Chạy cả `tests` trong một lượt thì treo ở các test dựng MainWindow trong `tests/ui`; `b7a41b3` cũng treo đúng chỗ đó. - `check_imports.py` và `check_orphan_modules.py` PASS. `check_loc.py` báo 9 file quá dài, giống hệt trước khi sửa (không file nào do nhánh này làm dài thêm). - Kiểm tra tay trên Windows 11: trong AppContainer, Python báo `WinError 10013`, ping/nslookup/PowerShell/curl đều không ra được mạng; cmd, git, python chạy bình thường. - Kiểm tra tay trên Windows 11: xem trước HTML tải được 4/4 tài nguyên web khi mạng mở, 0/4 khi bật chặn; tab Graph hoạt động; tạo/huỷ trang xem trước nhiều lần không còn cảnh báo profile của Qt. ## Security Impact Permission / credential / network / customer data impact: - Network: khi bật công tắc, chỉ nhà cung cấp AI còn ra mạng; nội dung chat vẫn gửi tới nhà cung cấp AI. - Permission: lần đầu chạy lệnh trong sandbox, app **thêm quyền (ACE) cho SID AppContainer** trên thư mục làm việc (ghi), thư mục cài Python gốc (đọc), gốc venv và `Scripts` (đọc). Không xoá quyền nào. Thư mục chứa PySide6 không bao giờ nhận quyền kế thừa; một quyền kế thừa sai trên venv (từ bản dev trước) được tự gỡ. - Credential: không đổi. Khi chặn, trạng thái đăng nhập M365 được đọc thẳng từ kho token trên máy, không dựng MSAL. ## Compatibility - [x] No breaking change - [ ] Breaking change documented Ghi chú: `block_network` mặc định đổi từ bật sang tắt cho cấu hình mới; máy đã lưu `true` thì giữ nguyên. Khi đang chặn, lệnh dùng công cụ cài trong thư mục người dùng (ngoài Program Files) có thể báo Access denied; thư viện trong venv của app không dùng được trong sandbox. ## Reviewer Notes - `infrastructure/sandbox/appcontainer_process.py` gọi Win32 bằng ctypes (CreateAppContainerProfile, CreateProcessW với SECURITY_CAPABILITIES) và dùng `icacls` để cấp quyền: nên xem kỹ phần cấp quyền. - `tests/conftest.py` thêm fixture autouse gỡ `network_guard` sau mỗi test, vì `build_context()` gắn cổng này ở mức process. - `core/task_executors.py` đang đúng bằng trần LOC nên `_run_script` được tách sang `core/task_script.py`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: minhanhpkpro <minhanhpkpro@gmail.com> Co-authored-by: Duy Le Huu <duylh19@fpt.com> Co-authored-by: thanhnv <thanhnv.ip@gmail.com> Reviewed-on: #13
503 lines
25 KiB
Python
503 lines
25 KiB
Python
"""Cowork tab — chat with the local Internal Agent."""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from PySide6.QtWidgets import (
|
|
QFileDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget,
|
|
)
|
|
|
|
from ..core import agent_roles
|
|
from ..core.worker import AgentWorker
|
|
from ..i18n import on_language_changed, tr
|
|
from ..state import AppContext
|
|
from .chat_panel import ChatPanel
|
|
from .icons import icon
|
|
|
|
_FOLDER_LBL_MAX_CHARS = 42 # keep the composer's bottom row from crowding out Agent/Send
|
|
|
|
|
|
class CoworkTab(ChatPanel):
|
|
"""Màn Cowork: khung chat chính, gắn với một project và một thư mục kết quả.
|
|
|
|
Khác :class:`ChatPanel` gốc ở chỗ kết quả được gom theo TỪNG hội thoại và
|
|
chỉ hiện ra sau khi lượt chạy thành công — xem :meth:`register_output`.
|
|
"""
|
|
def __init__(self, ctx: AppContext):
|
|
"""Màn Cowork — một ``ChatPanel`` kèm thanh công cụ riêng hiện provider, model và
|
|
thư mục kết quả đang dùng.
|
|
"""
|
|
super().__init__(ctx, "cowork", "Cowork", placeholder_key="composer.placeholder_cowork")
|
|
|
|
self._title_lbl = QLabel()
|
|
self._title_lbl.setStyleSheet("font-weight:700; font-size:15px;")
|
|
self.skills_btn = QPushButton()
|
|
self.skills_btn.setIcon(icon("book"))
|
|
self.skills_btn.clicked.connect(self._open_skills_manager)
|
|
|
|
self._new_btn = QPushButton()
|
|
self._new_btn.setIcon(icon("new"))
|
|
self._new_btn.clicked.connect(self.new_session)
|
|
|
|
self.toolbar_layout.addWidget(self._title_lbl)
|
|
self.toolbar_layout.addStretch(1)
|
|
self.toolbar_layout.addWidget(self.skills_btn)
|
|
self.toolbar_layout.addWidget(self._new_btn)
|
|
|
|
# Where Cowork saves its deliverables — the project's sandbox workspace
|
|
# (or, for a folder picked via the button, that folder). Only the
|
|
# folder NAME shows beside the button (not the full path as a
|
|
# link/hint text) — the full path is still available as a tooltip.
|
|
self.folder_btn = QPushButton()
|
|
self.folder_btn.setIcon(icon("folder"))
|
|
self.folder_btn.clicked.connect(self._pick_output_folder)
|
|
self.folder_lbl = QLabel("")
|
|
self.folder_lbl.setObjectName("hint")
|
|
folder_box = QWidget()
|
|
_fbl = QHBoxLayout(folder_box)
|
|
_fbl.setContentsMargins(0, 0, 0, 0)
|
|
_fbl.setSpacing(6)
|
|
_fbl.addWidget(self.folder_btn)
|
|
_fbl.addWidget(self.folder_lbl) # folder name BESIDE the button
|
|
# Which project (workspace) the CURRENT thread belongs to. Cowork now
|
|
# always lives INSIDE the Workspace screen for a single selected project,
|
|
# so the project name beside the composer is redundant chrome — the label
|
|
# is kept as a hidden member (its text still tracks the active project for
|
|
# tooltips/tests) but no longer added to the layout.
|
|
self.project_lbl = QLabel("")
|
|
self.project_lbl.setObjectName("hint")
|
|
self.project_lbl.setVisible(False)
|
|
self.composer.add_bottom_left(folder_box)
|
|
|
|
# Per-workspace "Auto-run" toggle (auto-approve commands in THIS
|
|
# workspace) — sits next to the routing toggle the base class added.
|
|
from .routing_toggle import AutoRunToggle
|
|
self.autorun_toggle = AutoRunToggle(self.ctx)
|
|
self.composer.add_bottom_right(self.autorun_toggle)
|
|
|
|
self.refresh_header()
|
|
# Start watching the output folder for new files
|
|
wd = self.workspace_dir()
|
|
if wd:
|
|
self._start_watching(wd)
|
|
# Cowork shows ONLY the final deliverables, refreshed from disk once a turn
|
|
# succeeds (see _cleanup_turn) — never intermediate files or a folder name.
|
|
on_language_changed(self._retranslate)
|
|
|
|
def refresh_title(self) -> None:
|
|
"""Head the screen with the thread you are in, as the drawing does.
|
|
|
|
It said "Cowork" on every conversation — the screen's own name, which
|
|
the rail already shows. The thread's title is the thing that changes and
|
|
the thing that tells you where you are; a thread with no title yet (a
|
|
new chat, before its first turn) falls back to the screen name.
|
|
"""
|
|
lbl = getattr(self, "_title_lbl", None)
|
|
if lbl is None:
|
|
return # ChatPanel.__init__ sets self.title before we exist
|
|
lbl.setText(getattr(self, "title", "") or tr("cowork.title"))
|
|
|
|
def _retranslate(self) -> None:
|
|
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề và các nút trên thanh công cụ."""
|
|
# ChatPanel dựng phần chrome dùng chung (nhãn Agent, nút Nén, tiêu đề ba
|
|
# bảng tệp) nhưng KHÔNG tự đăng ký dịch lại — lớp con là chỗ duy nhất có
|
|
# đăng ký, nên bỏ dòng này là toàn bộ phần đó đứng ở ngôn ngữ lúc dựng.
|
|
self._retranslate_base()
|
|
self.refresh_title()
|
|
self.skills_btn.setText(tr("cowork.skills_btn"))
|
|
self.skills_btn.setToolTip(tr("cowork.skills_tooltip"))
|
|
self._new_btn.setText(tr("cowork.new_chat"))
|
|
self.folder_btn.setText(tr("cowork.pick_folder_btn"))
|
|
self.folder_btn.setToolTip(tr("cowork.pick_folder_tooltip"))
|
|
self._apply_output_folder_label()
|
|
|
|
# ---- output folder --------------------------------------------------
|
|
def _pick_output_folder(self) -> None:
|
|
"""Chọn thư mục kết quả cho hội thoại này."""
|
|
start = str(self._session_output_dir())
|
|
chosen = QFileDialog.getExistingDirectory(self, tr("cowork.pick_folder_title"), start)
|
|
if not chosen:
|
|
return
|
|
if self.project_id not in ("", "default"):
|
|
# Inside a project, the picked folder becomes THAT project's
|
|
# workspace (its sandbox + shared-knowledge root) — not the
|
|
# global default-output setting.
|
|
from ..core.projects import save_project
|
|
from ..presentation.workspace.project_folder_rules import folder_taken_blocked
|
|
|
|
project = self._project()
|
|
if project is not None:
|
|
if folder_taken_blocked(self, chosen, self.project_id):
|
|
return
|
|
project.output_dir = chosen
|
|
save_project(project)
|
|
else:
|
|
self.ctx.config.cowork["output_dir"] = chosen
|
|
self.ctx.save()
|
|
self._apply_output_folder_label()
|
|
self._refresh_outputs_from_disk()
|
|
|
|
def _apply_output_folder_label(self) -> None:
|
|
"""Cập nhật nhãn cạnh nút chọn thư mục.
|
|
|
|
Chỉ hiện TÊN thư mục; đường dẫn đầy đủ để trong tooltip — bày cả đường dẫn
|
|
ra ngoài chỉ làm rối một giá trị mà người dùng vừa tự chọn.
|
|
"""
|
|
full = str(self._session_output_dir())
|
|
# Only the folder NAME is shown beside the button — the full path
|
|
# (still available on hover) reads as noisy clutter for a value the
|
|
# user just picked and already knows the location of.
|
|
name = Path(full).name or full
|
|
if len(name) > _FOLDER_LBL_MAX_CHARS:
|
|
name = name[:_FOLDER_LBL_MAX_CHARS - 1] + "…"
|
|
self.folder_lbl.setText(name)
|
|
self.folder_lbl.setToolTip(full)
|
|
# Text still tracks the active project (tooltip/tests read it), but the
|
|
# label stays hidden — see its creation note above.
|
|
project = self._project()
|
|
if project is not None:
|
|
self.project_lbl.setText(tr("cowork.project_label", name=project.name))
|
|
self.project_lbl.setToolTip(tr("cowork.project_tooltip", name=project.name))
|
|
else:
|
|
self.project_lbl.setText("")
|
|
# Start watching the output folder for new files
|
|
wd = self.workspace_dir()
|
|
if wd:
|
|
self._start_watching(wd)
|
|
|
|
# ---- project (workspace) --------------------------------------------
|
|
def _project(self):
|
|
"""Project đang gắn với hội thoại này; ``None`` nếu không tìm thấy."""
|
|
from ..core.projects import load_project
|
|
|
|
return load_project(self.project_id)
|
|
|
|
def set_project(self, project_id: str) -> None:
|
|
"""Assign the CURRENT thread to a project (called by the Workspace
|
|
screen's 'New chat in project'). Output folder + shared context follow.
|
|
|
|
Also makes this the ACTIVE workspace for per-workspace mode resolution
|
|
and refreshes the routing/auto-run toggles to show THIS workspace's
|
|
modes (so switching projects switches the visible modes)."""
|
|
self.project_id = project_id or "default"
|
|
self.ctx.active_project_id = self.project_id
|
|
for t in (getattr(self, "routing_toggle", None), getattr(self, "autorun_toggle", None)):
|
|
if t is not None:
|
|
t.refresh()
|
|
self._apply_output_folder_label()
|
|
self._refresh_outputs_from_disk()
|
|
|
|
def project_knowledge_dir(self):
|
|
"""A non-default project's shared-knowledge folder — its workspace
|
|
ROOT. This is now the SAME folder every thread of the project already
|
|
saves its deliverables into (no more per-session sub-folder — see
|
|
_session_output_dir), so ChatPanel._augment's equality check skips the
|
|
separate '[Project files]' scan and the '[Workspace files]' scan alone
|
|
already covers everything in one shared place, Claude-Projects style."""
|
|
if self.project_id in ("", "default"):
|
|
return None
|
|
project = self._project()
|
|
return project.workspace_dir() if project else None
|
|
|
|
# Generator/helper scripts are never a Cowork deliverable — keep them out of
|
|
# the Output list entirely (only the final file is shown).
|
|
_INTERMEDIATE_EXTS = {".py", ".pyw", ".js", ".mjs", ".cjs", ".ts",
|
|
".sh", ".bat", ".ps1", ".rb", ".pl"}
|
|
|
|
def assistant_title(self) -> str:
|
|
"""Nhãn hiện trên bong bóng trả lời của màn Cowork."""
|
|
return tr("cowork.assistant_title")
|
|
|
|
def _session_output_dir(self):
|
|
"""Where this session's deliverables are saved.
|
|
|
|
Default: a sub-folder of the configured Output root named by the
|
|
session id (a timestamp — never the chat content), so same-named
|
|
outputs from different sessions don't overwrite each other.
|
|
|
|
Once a folder has been EXPLICITLY specified — either "Chọn thư mục
|
|
khác" (see _pick_output_folder) for the default project, or a
|
|
project's own workspace folder — files are saved DIRECTLY into it
|
|
instead, no auto-created per-session sub-folder: the Output box/link
|
|
always points at exactly that folder, and every thread that shares it
|
|
sees the same files (deliverables AND project knowledge together,
|
|
Claude-Projects style). Running turns still get isolated '.turns/<id>'
|
|
sandboxes (unique across every session in this tab — see
|
|
_turn_output_dir/_turn_seq — moved up on success by
|
|
_promote_turn_outputs, which de-dupes by name via _unique_path), so
|
|
concurrent turns/threads sharing one folder never clash.
|
|
|
|
Threads of a NON-default project are sandboxed inside that project's
|
|
own workspace: the agent's tools are confined there (ToolContext
|
|
rejects any path escape), and it's a single shared folder for the
|
|
whole project — not one sub-folder per thread."""
|
|
if self.project_id not in ("", "default"):
|
|
project = self._project()
|
|
if project is not None:
|
|
return project.workspace_dir()
|
|
custom = (self.ctx.config.cowork.get("output_dir") or "").strip()
|
|
if custom:
|
|
return Path(custom).expanduser()
|
|
return self.ctx.config.cowork_output_dir() / self.session_id
|
|
|
|
def workspace_dir(self):
|
|
"""Thư mục agent được phép đọc/ghi trong hội thoại này."""
|
|
return self._session_output_dir()
|
|
|
|
def _turn_output_dir(self, turn_id: str):
|
|
"""Each running turn writes into its own '.turns/<id>' sandbox so parallel
|
|
turns never clobber each other's files (run_cowork's cleanup diffs the
|
|
folder before/after, which would misfire on a shared dir). On success the
|
|
finished turn's deliverables are moved up to the session root — see
|
|
_cleanup_turn. The dot-prefixed folder is ignored by the Output list
|
|
(which shows only top-level files)."""
|
|
return self._session_output_dir() / ".turns" / turn_id
|
|
|
|
def _is_intermediate_output(self, path: str) -> bool:
|
|
"""Tệp này có phải file trung gian (do bước dựng sinh ra) không.
|
|
|
|
File trung gian không hiện lên ô "Tệp đầu ra" — người dùng chỉ quan tâm
|
|
sản phẩm cuối.
|
|
"""
|
|
from pathlib import Path
|
|
return Path(path).suffix.lower() in self._INTERMEDIATE_EXTS
|
|
|
|
def _existing_output_names(self):
|
|
"""Deliverables already sitting in this session's output folder (from
|
|
earlier, already-finished turns) — the current turn writes into its own
|
|
'.turns/<id>' sandbox, so this never includes its own in-flight files."""
|
|
try:
|
|
return sorted(p.name for p in self._session_output_dir().iterdir()
|
|
if p.is_file() and not p.name.startswith(".")
|
|
and not self._is_intermediate_output(str(p)))
|
|
except OSError:
|
|
return []
|
|
|
|
def _session_notes(self) -> str:
|
|
# Lets the agent (and the user, without re-uploading) reference/revise a
|
|
# file it made earlier in this same conversation — e.g. "sửa lại tiêu đề
|
|
# trong file báo cáo vừa tạo" — since it can read_file/edit_file/write_file
|
|
# it directly by the exact name listed here.
|
|
"""Ghi chú đính kèm vào prompt: danh sách tệp hội thoại này đã tạo.
|
|
|
|
Nhờ đó agent (và người dùng, không phải tải lên lại) tham chiếu và sửa được
|
|
tệp nó vừa tạo ở lượt trước — ví dụ "sửa lại tiêu đề trong file báo cáo vừa
|
|
tạo" — vì nó đọc/ghi được tệp đó theo đúng tên liệt kê ở đây.
|
|
"""
|
|
names = self._existing_output_names()
|
|
if not names:
|
|
return ""
|
|
listing = ", ".join(names)
|
|
return (
|
|
"[Session context] Files already created earlier in this conversation's output "
|
|
"folder — read/revise them directly by their exact name with read_file/edit_file/"
|
|
"write_file; the user does NOT need to re-upload them if they refer to \"the file "
|
|
f"I made\" / \"file vừa tạo\" or similar: {listing}"
|
|
)
|
|
|
|
def _promote_turn_outputs(self, turn_dir, record, session_root) -> None:
|
|
"""Move a finished turn's files from its '.turns/<id>' sandbox up to its
|
|
conversation's Output root (``session_root`` — the turn's HOME session, so a
|
|
background turn lands in the right chat even after the user switched away),
|
|
then remove the sandbox. run_cowork already flattened and stripped
|
|
'.scratch'/generator scripts, so the sandbox root holds the final file(s).
|
|
Every top-level file is moved (no extension filtering) so a file the user
|
|
genuinely asked for is never dropped before the sandbox is removed — the
|
|
Output list itself decides what to *show* (see _refresh_outputs_from_disk)."""
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
from ..core.chat_agent import _unique_path
|
|
|
|
turn_dir = Path(turn_dir)
|
|
session_root = Path(session_root)
|
|
try:
|
|
if not turn_dir.is_dir() or turn_dir.resolve() == session_root.resolve():
|
|
return
|
|
except OSError:
|
|
return
|
|
session_root.mkdir(parents=True, exist_ok=True)
|
|
remap = {}
|
|
try:
|
|
files = [p for p in turn_dir.iterdir() if p.is_file()]
|
|
except OSError:
|
|
files = []
|
|
for p in files:
|
|
try:
|
|
dest = _unique_path(session_root, p.name)
|
|
p.replace(dest)
|
|
remap[str(p)] = str(dest)
|
|
except OSError:
|
|
pass
|
|
shutil.rmtree(turn_dir, ignore_errors=True)
|
|
# Keep this turn's recorded outputs pointing at the moved files so
|
|
# deleting the message later still finds and removes them.
|
|
if remap and record is not None:
|
|
record["outputs"] = [remap.get(p, p) for p in record.get("outputs", [])]
|
|
|
|
# ---- Output box: only the final, successful deliverables ----------
|
|
def register_output(self, path: str) -> None:
|
|
# Suppress live/intermediate updates during a run — the Output box is
|
|
# rebuilt from the surviving files once the turn succeeds (see below).
|
|
"""Không làm gì: ô "Tệp đầu ra" của Cowork được dựng lại từ các tệp còn sống
|
|
sau khi lượt chạy THÀNH CÔNG, chứ không cập nhật từng tệp giữa chừng.
|
|
"""
|
|
return
|
|
|
|
def on_file_written(self, path: str) -> None:
|
|
"""Không làm gì: Cowork không cập nhật danh sách tệp theo thời gian thực."""
|
|
return # no live file updates in Cowork
|
|
|
|
def _refresh_outputs_from_disk(self) -> None:
|
|
"""Show only the final deliverable files now sitting in the session folder
|
|
(no scripts/intermediate files, no hidden/`.scratch`, no folders)."""
|
|
self.output_section.clear()
|
|
try:
|
|
files = sorted((p for p in self._session_output_dir().iterdir() if p.is_file()),
|
|
key=lambda p: p.name)
|
|
except OSError:
|
|
return
|
|
for p in files:
|
|
if not p.name.startswith(".") and not self._is_intermediate_output(str(p)):
|
|
self.output_section.add(str(p))
|
|
self.output_changed.emit(str(self._session_output_dir()))
|
|
|
|
def new_session(self) -> None:
|
|
# The new thread stays in the CURRENT project (Claude-style).
|
|
"""Mở hội thoại mới trong ĐÚNG project đang chọn, rồi trỏ lại thư mục kết quả."""
|
|
super().new_session()
|
|
# Refresh the project/folder labels + watch the new session's folder.
|
|
self._apply_output_folder_label()
|
|
|
|
def load_conversation(self, conv) -> None:
|
|
"""Mở lại một hội thoại cũ: khôi phục project của nó, dựng lại danh sách tệp
|
|
đầu ra từ đĩa và trỏ lại nhãn thư mục.
|
|
"""
|
|
super().load_conversation(conv) # restores this conversation's project_id
|
|
self._refresh_outputs_from_disk() # show this session's deliverables from disk
|
|
# Refresh the project/folder labels + watch this conversation's folder.
|
|
self._apply_output_folder_label()
|
|
|
|
def refresh_header(self) -> None:
|
|
"""Cập nhật các nhãn trên thanh công cụ Cowork.
|
|
|
|
Dòng "provider · model" từng nằm ngay sau chữ "Cowork" đã được gỡ: nó
|
|
lặp lại thông tin mà bộ chọn provider ở thanh trên đang hiển thị, và
|
|
chiếm chỗ đắt nhất trên thanh công cụ cho một thứ chỉ để đọc.
|
|
"""
|
|
self._apply_output_folder_label() # picks up edits made via Settings too
|
|
|
|
def build_job(self, text: str, messages, out_dir):
|
|
"""This turn's job: a frozen request run through the conversation service.
|
|
|
|
Since R04-T04 the widget no longer drives the turn loop. Every value a
|
|
turn depends on is read HERE, on the UI thread at submit time, and packed
|
|
into an immutable ``ConversationExecutionRequest`` — so clicking a
|
|
different model or switching workspace mid-answer cannot reach work
|
|
already in flight.
|
|
"""
|
|
output_dir = out_dir or self._session_output_dir()
|
|
# The sandbox folder is named by the turn id ('.turns/t3'); with no
|
|
# sandbox the session id identifies the turn well enough for the audit log.
|
|
turn_id = out_dir.name if out_dir is not None else self.session_id
|
|
session_id = self.session_id
|
|
title = self.title
|
|
project_id = self.project_id
|
|
home_output_root = self.workspace_dir()
|
|
# Captured at submit time (UI thread): the Admin-defined agent
|
|
# preset's instructions, if one is selected in the Agent picker.
|
|
agent_prompt = self.admin_agent_prompt()
|
|
# Per-workspace Auto-run override wins, else the global "confirm before
|
|
# running commands" setting. Frozen now, so a Settings change mid-turn
|
|
# cannot flip the rules this turn started under.
|
|
confirm_commands = self.ctx.project_confirm_commands()
|
|
# What the turn is recorded as running on. A routing override (R03) wins
|
|
# over the tab's own picker; '' means the provider's configured default.
|
|
# Informational only — an Admin-agent preset builds its own provider
|
|
# below, so treat these as the record, not the decision.
|
|
provider_id = self._routed_provider or self.ctx.config.active_provider
|
|
model = self._routed_model or self._model or ""
|
|
|
|
def job(worker: AgentWorker):
|
|
"""Chạy nền một lượt Cowork qua ``ConversationApplicationService`` (R04-T04).
|
|
|
|
Sự kiện của service được dịch ngược về khuôn dict cũ để giao diện hiện tại
|
|
không phải sửa.
|
|
"""
|
|
from ..application.conversations.core_runtime_adapter import (
|
|
build_cowork_conversation_service,
|
|
legacy_event_sink,
|
|
)
|
|
from ..application.conversations.cowork_turn_request import (
|
|
build_cowork_turn_request,
|
|
)
|
|
from ..application.conversations.turn_runtime import combine_instructions
|
|
from ..core.projects import load_project, project_context_text
|
|
|
|
provider = self.build_provider() # this tab's selected agent/model
|
|
# 🔌 MCP Layer: every tool source flows through MCP now — the
|
|
# external servers configured in Settings AND Microsoft 365 (a
|
|
# built-in MCP server auto-registered while signed in, see
|
|
# AppContext._ms365_builtin_connection / mcp_servers/ms365_server.py).
|
|
extra_tools, extra_exec = self.ctx.build_mcp_tools()
|
|
# Shared project instructions (Claude-Projects style) plus the Admin
|
|
# agent's persona, refreshed each turn so edits in the Workspace
|
|
# screen apply immediately.
|
|
instructions = combine_instructions(
|
|
project_context_text(load_project(project_id)), agent_prompt)
|
|
# Permission Management (Sandbox Security Layer): off by default —
|
|
# matches the pre-existing auto-run behavior. The gate lives on the
|
|
# worker because the UI resolves it from the main thread.
|
|
gate = None
|
|
if confirm_commands:
|
|
gate = worker.new_gate("confirm", agent_role=agent_roles.COWORK)
|
|
|
|
service = build_cowork_conversation_service(
|
|
provider, output_dir, worker.emit_event, title=title,
|
|
project_context=instructions, extra_tools=extra_tools,
|
|
extra_executor=extra_exec, security_config=self.ctx.config,
|
|
gate=gate, agent_role=agent_roles.COWORK,
|
|
)
|
|
request = build_cowork_turn_request(
|
|
turn_id=turn_id, session_id=session_id, surface=self.kind,
|
|
project_id=project_id, title=title, messages=messages,
|
|
provider_id=provider_id, model=model, instructions=instructions,
|
|
output_dir=output_dir, home_output_root=home_output_root,
|
|
confirm_commands=gate is not None, agent_role=agent_roles.COWORK,
|
|
)
|
|
# Hand the widget's own list over: _reattach_running_turn replays
|
|
# from it while the turn is still running, and _finalize_turn slices
|
|
# it afterwards, so the service must append into that very object.
|
|
service.execute(request, legacy_event_sink(worker.emit_event),
|
|
cancel=worker.is_cancelled, messages=messages)
|
|
return {"messages": messages, "turn_dir": str(output_dir)}
|
|
|
|
return job
|
|
|
|
def _cleanup_turn(self, ctx, ok) -> None:
|
|
"""A turn ended (successfully or not): promote whatever files it
|
|
produced up to ITS conversation's Output root, THEN discard the
|
|
(by then empty, or intermediate-only) sandbox.
|
|
|
|
This runs the SAME promotion on failure as on success — a turn can
|
|
genuinely create a deliverable (e.g. save_file succeeds) and THEN hit
|
|
an unrelated error later in the same turn (a follow-up tool call, a
|
|
network drop, a rate limit that didn't recover) which raises and
|
|
marks the whole turn as failed. Discarding the sandbox unconditionally
|
|
in that case would silently delete a file the user actually got —
|
|
exactly the "file đã tạo bị xóa" bug. Promoting first is always safe:
|
|
an empty/intermediate-only sandbox just promotes zero files.
|
|
|
|
Refresh the visible Output list only when that conversation is the
|
|
one on screen."""
|
|
turn_dir = ctx.get("out_dir")
|
|
home_root = ctx.get("home_out_root")
|
|
live = ctx.get("home_id") == self.session_id and not ctx.get("detached")
|
|
if turn_dir and home_root:
|
|
self._promote_turn_outputs(turn_dir, ctx.get("record"), home_root)
|
|
elif turn_dir:
|
|
import shutil
|
|
shutil.rmtree(turn_dir, ignore_errors=True)
|
|
if live:
|
|
self._refresh_outputs_from_disk()
|