Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddb31f9aff | ||
|
|
b500b3e57d | ||
|
|
76225aa118 | ||
|
|
35334274eb | ||
|
|
10b8379824 | ||
|
|
8c497cf50a | ||
|
|
b78d48320c | ||
|
|
b7a41b3658 | ||
|
|
bbdf146d2c | ||
|
|
35f24e0e28 | ||
|
|
2759ed94ba | ||
|
|
8548c1e923 | ||
|
|
caf3b74931 | ||
|
|
c00b83cd1a | ||
|
|
a04f8a928d | ||
|
|
cc8d5c8c0a | ||
|
|
2e3e719259 | ||
|
|
c699beb6fd | ||
|
|
7607f44030 | ||
|
|
cbae2604db | ||
|
|
b71a622227 | ||
|
|
1b8429e33a | ||
|
|
13e2c22067 |
@@ -0,0 +1,82 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project
|
||||
|
||||
Cowork Local (user-facing brand "Cowork-Local BamBOO") is a local-first PySide6 desktop app: multi-turn AI agents, per-project workspaces with GraphRAG, scheduled agent tasks (Kanban), MCP connectors, a sandbox security layer, model routing, and a monitoring dashboard. User config lives in `~/.cowork_local`. The internal name `cowork_local` / `APP_NAME` must not be rebranded. Only `DISPLAY_NAME` is the brand.
|
||||
|
||||
## The package-name quirk (read first)
|
||||
|
||||
The repository root **is** the `cowork_local` package: `__init__.py` and `__main__.py` sit at the root, and code imports itself as `cowork_local.*` or through relative imports. This checkout's folder is not named `cowork_local`, so:
|
||||
|
||||
- **Running the app:** `python -m cowork_local` works only from the parent of a folder literally named `cowork_local`. On Windows, `install.bat` (once; add `--dev` for test deps, `--system` to skip the venv) builds a venv under `%LOCALAPPDATA%\CoworkLocal` and creates a junction `%LOCALAPPDATA%\CoworkLocal\launcher\<key>\cowork_local` pointing at this checkout. After that, use `run.bat`. The MS365 MCP server is spawned as `python -m cowork_local.mcp_servers.ms365_server`, so the junction is needed for subprocesses too.
|
||||
- **Never create a `.venv` inside the repo.** The quality gates walk the whole tree.
|
||||
- **Tests:** the root `conftest.py` and `tests/conftest.py` bind `sys.modules["cowork_local"]` to this checkout, so pytest works whatever the folder is named. Tests use both import styles: top-level (`from providers.base import ...`) and qualified (`from cowork_local.core... import ...`). Characterization tests that spawn `python -c "from cowork_local..."` subprocesses still need a real `cowork_local` directory on `PYTHONPATH`. CI checks out into `cowork_local/` for this reason.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
python -m pip install -r requirements.txt # single requirements file (includes pytest)
|
||||
python -m pytest tests -q # full suite (what CI runs)
|
||||
python -m pytest tests/unit/test_schedule_calculator.py -q # one file
|
||||
python -m pytest tests/unit/test_schedule_calculator.py -k name -q # one test
|
||||
python -m pytest tests/e2e/test_smoke.py -v # release smoke test
|
||||
|
||||
python scripts/run_quality_gate.py # all CASAN gates + pytest
|
||||
python scripts/run_quality_gate.py --skip-tests # static gates only
|
||||
python scripts/check_imports.py # domain/ + application/ must not import PySide6/PyQt/ui/app
|
||||
python scripts/audit_security.py # no plaintext credentials (CI also runs --self-test)
|
||||
python scripts/check_loc.py # <= 400 lines per production file
|
||||
python scripts/check_orphan_modules.py # every production module must be reachable by import
|
||||
```
|
||||
|
||||
Widget tests run headless with `QT_QPA_PLATFORM=offscreen`. `tools/check_*.py` are standalone offscreen smoke checkers against a real `MainWindow` built on a copy of `~/.cowork_local` (for example `python tools/check_nav.py`). Some of them still import private names re-exported from `app.py`, so keep those re-exports. Set `COWORK_PERF_TRACE=1` to log timing spans from `performance.py`.
|
||||
|
||||
## Architecture
|
||||
|
||||
Target design is 4-tier Clean Architecture (`docs/architecture/ADR-001-layered-architecture.md`):
|
||||
|
||||
- `domain/`: pure stdlib entities, frozen request snapshots (`ConversationExecutionRequest`), `AgentEvent`, tool/provider descriptors, `ScheduleCalculator`.
|
||||
- `application/`: pure-Python use-case services (conversations, model_routing, scheduling, workspaces, monitoring, workflows). **No Qt.** Must run headless.
|
||||
- `infrastructure/`: adapters: config (`JsonConfigRepository` over `AtomicJsonFile`), OS-keyring `SecretStore`, providers, MCP (`McpToolSourceManager`), sandbox, filesystem, telemetry, and Qt bridges (`infrastructure/qt`).
|
||||
- `presentation/`: PySide6 widgets by feature (`shell`, `chat`, `co4e`, `dashboard`, `scheduling`, `workspace`, `monitoring`, `graph`, `settings`, ...). Widgets call `application/` services. They don't touch persistence or run LLM calls on the GUI thread. Agent work runs in worker threads and reaches the UI as `AgentEvent`s through Qt signal bridges.
|
||||
|
||||
The refactor is **incomplete**. Legacy top-level packages are still live and imported by the app:
|
||||
- `ui/`: older tabs such as `cowork_tab`, `workspace_tab`, `monitoring_tab`, `settings_dialog`, `chat_panel`, and `co4e_*`.
|
||||
- `core/`: agents, the Co4E flow runner, task scheduler, skills, tools, audit/usage tracking, routing.
|
||||
- `providers/`: `base`, `anthropic`, `openai_compat`, `factory`.
|
||||
- `security/`: validators, command risk classifier.
|
||||
- Root modules: `config.py`, `state.py`.
|
||||
|
||||
`docs/architecture/dormant-code.md` lists deprecated pieces, such as `state.py::active_project_id` and the monolithic `core/tools.py`. New layers must not import dormant code.
|
||||
|
||||
Wiring:
|
||||
- `__main__.py` → `app.run()`.
|
||||
- `presentation/shell/bootstrap.py` is the **Composition Root**. It builds `AppContext` (`state.py`) around `JsonConfigRepository` plus `KeyringAdapter`, falling back to the config file when no keyring exists.
|
||||
- `run()` then seeds the built-in skills (`skill_library/*.skill`) and the Co4E flows, applies the theme, and opens `presentation/shell/main_window.MainWindow`.
|
||||
- Pages are registered in `presentation/shell/page_registry.py`.
|
||||
|
||||
Other cross-cutting pieces:
|
||||
- `i18n/`: `tr(key, **kw)` with en/ja/vi (default `vi`). Long-lived widgets must use `bind_text(...)` or `on_language_changed(...)` so a language switch re-applies their text. Transient dialogs just call `tr()` at construction.
|
||||
- `theme/`: palettes and QSS. Use theme tokens, not hard-coded colors.
|
||||
- `mcp_servers/`: bundled MCP servers (MS365, project_context).
|
||||
- `agent/`: a markdown instruction library for UI/UX bug-fix agents, not runtime code.
|
||||
|
||||
Step-by-step recipes for adding a provider, a tool or MCP server, or a screen are in `docs/governance/contributor-recipes.md`.
|
||||
|
||||
## Rules enforced by gates and review
|
||||
|
||||
- **400-line limit** for every production file. This covers `domain`, `application`, `infrastructure`, `presentation`, `ui`, `core`, `providers`, `security`, `mcp_servers`, `i18n`, `theme`, and root `.py` files. Legacy oversized files have per-file caps in `scripts/check_loc.py` that may only go down. Split files; never raise a cap.
|
||||
- **No orphan modules.** `check_orphan_modules.py` has an `ALLOWLIST` that may only shrink. Wire up or delete a module instead of allowlisting it.
|
||||
- **Secrets** belong in the OS keyring, never in `config.json` or the code. `.env.example` is not auto-loaded. Tests never use live provider credentials (use `tests/fakes/`: `FakeProvider`, `FakeToolExecutor`, `FakeToolPolicyGateway`, `FakeConfigRepository`/`FakeSecretStore`, `FakeClock`, ...).
|
||||
- **Test layout:** `tests/unit` (fast, no I/O), `tests/contracts`, `tests/integration` (Qt offscreen), `tests/characterization` (pinned legacy behavior during refactors), `tests/e2e`, `tests/ui`, plus flat `tests/test_*.py`.
|
||||
- **Startup housekeeping** (seeding, pruning) is wrapped in `try/except` and must never block app launch.
|
||||
- **Comments:** the ADR asks for English comments. Existing code mixes English and Vietnamese docstrings and comments, so match the surrounding file.
|
||||
|
||||
## Git workflow
|
||||
|
||||
- Branches: `feat/`, `fix/`, `test/`, `docs/`, `perf/`, `refactor/<short-desc>`. Core AI work uses `core-ai/<task-id>-<name>`.
|
||||
- Commits use Conventional Commit prefixes (`feat:`, `fix:`, `test:`, `docs:`, `refactor:`, `perf:`, `chore:`).
|
||||
- One logical change per PR, using `.gitea/PULL_REQUEST_TEMPLATE.md`. The remote is a Gitea instance, and CI (`.gitea/workflows/ci.yaml`, Python 3.11) runs on PRs to `main`.
|
||||
- Critical areas listed in `SECURITY.md` get extra review.
|
||||
Binary file not shown.
+1
-1
@@ -15,7 +15,7 @@ network control, permission management, audit log). No login required —
|
||||
starts directly with full admin access.
|
||||
"""
|
||||
|
||||
__version__ = "2.26.0"
|
||||
__version__ = "0.0.1"
|
||||
# Internal/technical name — config dir (~/.cowork_local), QSettings org keys,
|
||||
# packaging scripts and docs still use this; do NOT rebrand it.
|
||||
APP_NAME = "Cowork Local"
|
||||
|
||||
@@ -111,6 +111,28 @@ Ba luật:
|
||||
chúng tự dựng `QLabel` bên trong, không giữ tham chiếu nào để áp lại. Truyền
|
||||
`bind_text(QLabel(), key)` hoặc truyền **khoá** thay vì chuỗi đã dịch.
|
||||
|
||||
### 2.0b. Nút do CHÍNH Qt vẽ chữ — `ui/dialog_buttons.py`
|
||||
|
||||
`tr()` không với tới được nhãn nút của mấy widget dựng sẵn: Qt lấy chữ từ bảng dịch của
|
||||
riêng nó, mà ứng dụng không cài `QTranslator` nào (bản PySide6 đang dùng cũng không đóng
|
||||
gói file `qtbase_*.qm` nào để cài). Kết quả: **luôn là tiếng Anh ở cả ba ngôn ngữ.**
|
||||
|
||||
| Không dùng | Dùng thay |
|
||||
| --- | --- |
|
||||
| `QDialogButtonBox(Save \| Cancel)` | `dialog_buttons(Save \| Cancel)` |
|
||||
| `QMessageBox.question(...) == QMessageBox.Yes` | `confirm(parent, title, body)` |
|
||||
| `QInputDialog.getText / getMultiLineText / getItem` | `ask_text` / `ask_multiline` / `ask_item` |
|
||||
|
||||
Muốn một nút mang chữ riêng thì truyền khoá vào `dialog_buttons`, **không** `setText(tr(...))`
|
||||
sau khi dựng — lần đổi ngôn ngữ kế tiếp, ràng buộc sẽ áp lại khoá mặc định và xoá mất chữ đó:
|
||||
|
||||
```python
|
||||
self.buttons = dialog_buttons(QDialogButtonBox.Ok | QDialogButtonBox.Cancel,
|
||||
ok="schedtask.ai_confirm")
|
||||
```
|
||||
|
||||
Ba cổng trong `tests/ui/test_i18n_khong_hardcode_chu.py` canh việc này.
|
||||
|
||||
### 2.1. Widget sống lâu
|
||||
|
||||
Ví dụ:
|
||||
@@ -338,6 +360,9 @@ Trước khi hoàn thành bản vá i18n, phải kiểm tra:
|
||||
|
||||
* [ ] Không còn chuỗi hardcode mới trong bản vá?
|
||||
|
||||
* [ ] Nút hộp thoại đi qua `ui/dialog_buttons.py` (mục 2.0b), không dựng
|
||||
`QDialogButtonBox` / `QMessageBox.question` / `QInputDialog.get*` trực tiếp?
|
||||
|
||||
* [ ] Layout vẫn đúng với **chuỗi dài nhất** trong 3 ngôn ngữ?
|
||||
|
||||
* [ ] Không dùng `len()` để tính chiều rộng text?
|
||||
|
||||
@@ -74,6 +74,14 @@ class CoreToolRuntime:
|
||||
đều phải tra tên, tra trên danh sách sẽ chậm dần theo số tool.
|
||||
"""
|
||||
self._output_dir = Path(output_dir)
|
||||
# Every sandboxed tool (run_command included) gets this as its cwd —
|
||||
# it must exist BEFORE the first tool call, same as the older
|
||||
# run_cowork() (core/chat_agent.py) already does at its output_dir.
|
||||
# Without this, a per-turn ".turns/<id>" folder that was never created
|
||||
# makes run_command's subprocess.Popen(cwd=...) fail immediately with
|
||||
# WinError 267 ("directory name is invalid") before the command even
|
||||
# starts — no network, no output, just an opaque OS error.
|
||||
self._output_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._title = title
|
||||
self._extra_tools = list(extra_tools or ())
|
||||
self._extra_names = {getattr(t, "name", "") for t in self._extra_tools}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Application services for the "Block network" switch (Sandbox Security Layer)."""
|
||||
|
||||
from .network_guard import NetworkBlockedError, bind, ensure_allowed, is_blocked, refusal
|
||||
|
||||
__all__ = ["NetworkBlockedError", "bind", "ensure_allowed", "is_blocked", "refusal"]
|
||||
@@ -0,0 +1,61 @@
|
||||
"""One gate for every outbound connection the app makes on its own.
|
||||
|
||||
The "Block network" switch (``agent_security.block_network``) used to be read
|
||||
only where agent tools run, so Microsoft 365, Teams, connector test buttons,
|
||||
scheduled task scripts, pip auto-installs and HTML previews still reached the
|
||||
internet while Monitoring said "Network: blocked". Each of those now asks
|
||||
this module first.
|
||||
|
||||
The AI provider path (chat, model list, model test) deliberately does NOT go
|
||||
through here: with the switch on the user still talks to the model, but the
|
||||
app fetches nothing else on the model's or its own behalf.
|
||||
|
||||
The module holds a *reader*, not a copy of the flag: the Composition Root
|
||||
binds it to the live config once (``presentation/shell/bootstrap.py``), so a
|
||||
change saved in Settings takes effect on the very next call. Nothing bound
|
||||
(unit tests, helper subprocesses) means "not blocked", the pre-switch default.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Callable, Optional
|
||||
|
||||
_lock = threading.Lock()
|
||||
_reader: Optional[Callable[[], bool]] = None
|
||||
|
||||
|
||||
class NetworkBlockedError(PermissionError):
|
||||
"""Raised by :func:`ensure_allowed` while the switch is on."""
|
||||
|
||||
|
||||
def bind(reader: Optional[Callable[[], bool]]) -> None:
|
||||
"""Install the callable that says whether the network is blocked right now."""
|
||||
global _reader
|
||||
with _lock:
|
||||
_reader = reader
|
||||
|
||||
|
||||
def is_blocked() -> bool:
|
||||
"""True while "Block network" is on. A failing reader counts as blocked."""
|
||||
reader = _reader
|
||||
if reader is None:
|
||||
return False
|
||||
try:
|
||||
return bool(reader())
|
||||
except Exception: # noqa: BLE001 - fail closed: an unreadable switch must not open the network
|
||||
return True
|
||||
|
||||
|
||||
def refusal(purpose: str) -> str:
|
||||
"""The message shown (to the user or the model) when ``purpose`` is refused."""
|
||||
return (f"{purpose}: network access is blocked by the Sandbox Security Layer "
|
||||
"(\"Block network\" is on in Settings). Only the AI provider may be reached.")
|
||||
|
||||
|
||||
def ensure_allowed(purpose: str) -> None:
|
||||
"""Raise :class:`NetworkBlockedError` if ``purpose`` may not go online now."""
|
||||
if is_blocked():
|
||||
raise NetworkBlockedError(refusal(purpose))
|
||||
|
||||
|
||||
__all__ = ["NetworkBlockedError", "bind", "ensure_allowed", "is_blocked", "refusal"]
|
||||
@@ -21,9 +21,14 @@ def pptx_available() -> bool:
|
||||
try:
|
||||
from cowork_local.core.deps import ensure_module
|
||||
|
||||
_PPTX_READY = ensure_module("pptx", "python-pptx") is not None
|
||||
ready = ensure_module("pptx", "python-pptx") is not None
|
||||
except Exception: # noqa: BLE001
|
||||
_PPTX_READY = False
|
||||
ready = False
|
||||
from ..network import network_guard
|
||||
|
||||
if ready or not network_guard.is_blocked():
|
||||
_PPTX_READY = ready # a refusal under "Block network" is retried later
|
||||
return ready
|
||||
return _PPTX_READY
|
||||
|
||||
|
||||
|
||||
@@ -100,11 +100,15 @@ DEFAULT_CONFIG: Dict[str, Any] = {
|
||||
"resource_limit_cpu_percent": 80, # 0 = unlimited; caps a run_command/install_package process TREE's total CPU%
|
||||
"resource_limit_memory_mb": 2048, # 0 = unlimited; caps total RSS memory (MB)
|
||||
"resource_limit_disk_mb": 512, # 0 = unlimited; caps total disk read+write (MB)
|
||||
"block_network": True, # strip proxy env / point at a black-hole address for agent-run commands
|
||||
# "Block network": every outbound connection except the AI provider is
|
||||
# refused (application/network/network_guard.py), and agent shell
|
||||
# commands / task scripts run in a network-less AppContainer.
|
||||
# OFF on first launch — the user turns it on in Settings.
|
||||
"block_network": False,
|
||||
# Allow the agent's fetch_url tool to read web pages / online documents /
|
||||
# SharePoint-OneDrive share links. SEPARATE from block_network (that only
|
||||
# sandboxes agent-run shell commands) — reading a URL for info is safe and
|
||||
# useful, so this defaults ON. Toggle in Settings → Security.
|
||||
# SharePoint-OneDrive share links. Its own toggle — reading a URL for info
|
||||
# is safe and useful, so this defaults ON — but block_network outranks it:
|
||||
# with the network blocked the tool is refused either way.
|
||||
"allow_url_fetch": True,
|
||||
"sandbox_pw": "", # set through COWORK_SANDBOX_PASSWORD
|
||||
"rulebase_path": "", # custom RULEBASE.md — attached to every agent execution
|
||||
|
||||
@@ -527,6 +527,15 @@ def run_cowork(
|
||||
preview = {"kind": "info", "title": name, "text": str(args)}
|
||||
emit({"type": "tool_proposed", "id": tc_id, "name": name, "args": args,
|
||||
"preview": preview})
|
||||
if ctx.block_network and not name.startswith("ms365_local__"):
|
||||
result = {"ok": False, "output": (
|
||||
f"{name}: network access is blocked by the Sandbox Security Layer "
|
||||
'("Block network for agent-run commands" is on in Settings).')}
|
||||
emit({"type": "tool_result", "id": tc_id, "name": name,
|
||||
"ok": False, "output": result["output"]})
|
||||
messages.append({"role": "tool", "tool_call_id": tc_id, "name": name,
|
||||
"content": result["output"]})
|
||||
continue
|
||||
# R05-T04: MCP/connector tools used to run with NO permission
|
||||
# check at all — this is what closes that gap. Same policy,
|
||||
# same gate object as the built-in tools below.
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Mirror a OneDrive/SharePoint folder to/from a local directory (DF-007).
|
||||
|
||||
This is deliberately NOT a general sync engine: every existing tool
|
||||
(``run_command``, ``read_file``, ``write_file``...) operates on a real local
|
||||
``Path`` (``Project.output_dir`` — see ``core/projects.py::Project.workspace_dir``),
|
||||
and that contract does not change here. A cloud-backed project's
|
||||
``output_dir`` still points at a real local folder; this module only knows how
|
||||
to pull that folder's content down from Graph once, and push it back up once,
|
||||
both on explicit user action (a button click) — there is no background
|
||||
watcher, no continuous sync, no delete propagation, and no conflict
|
||||
resolution beyond "whichever side ran last wins" for a given file. See the
|
||||
DF-007 plan for why: OneDrive/SharePoint sync-client detection is unreliable,
|
||||
so a local mirror + manual sync is the only predictable option that does not
|
||||
touch the sandboxed command/file tools.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
from . import ms365_graph as graph
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncReport:
|
||||
"""Kết quả một lượt tải xuống/đẩy lên — hiển thị cho người dùng sau khi chạy."""
|
||||
transferred: int = 0
|
||||
skipped_too_large: List[str] = field(default_factory=list)
|
||||
errors: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _list_children(token: str, cloud_source: Dict[str, str], remote_path: str) -> List[dict]:
|
||||
provider = cloud_source.get("provider")
|
||||
if provider == "sharepoint":
|
||||
return graph.list_sharepoint_files(token, cloud_source["site_id"], remote_path)
|
||||
return graph.list_onedrive_files(token, remote_path)
|
||||
|
||||
|
||||
def _download_file(token: str, cloud_source: Dict[str, str], remote_path: str) -> bytes:
|
||||
if cloud_source.get("provider") == "sharepoint":
|
||||
return graph.download_sharepoint_file_bytes(token, cloud_source["site_id"], remote_path)
|
||||
return graph.download_onedrive_file_bytes(token, remote_path)
|
||||
|
||||
|
||||
def _upload_file(token: str, cloud_source: Dict[str, str], remote_path: str, data: bytes) -> None:
|
||||
if cloud_source.get("provider") == "sharepoint":
|
||||
graph.upload_sharepoint_file_bytes(token, cloud_source["site_id"], remote_path, data)
|
||||
else:
|
||||
graph.upload_onedrive_file_bytes(token, remote_path, data)
|
||||
|
||||
|
||||
def download_folder(token: str, cloud_source: Dict[str, str], local_dir: Path) -> SyncReport:
|
||||
"""Tải toàn bộ cây thư mục ``cloud_source['remote_path']`` xuống ``local_dir``,
|
||||
giữ nguyên cấu trúc thư mục con. Ghi đè file local nếu đã tồn tại (một
|
||||
chiều: cloud thắng). Không xoá file local nào không còn ở phía cloud."""
|
||||
report = SyncReport()
|
||||
root_remote = cloud_source.get("remote_path", "")
|
||||
local_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _walk(remote_path: str, local_sub: Path) -> None:
|
||||
try:
|
||||
children = _list_children(token, cloud_source, remote_path)
|
||||
except graph.Ms365GraphError as exc:
|
||||
report.errors.append(f"{remote_path or '/'}: {exc}")
|
||||
return
|
||||
for item in children:
|
||||
name = item.get("name", "")
|
||||
if not name:
|
||||
continue
|
||||
child_remote = f"{remote_path}/{name}" if remote_path else name
|
||||
child_local = local_sub / name
|
||||
if "folder" in item:
|
||||
child_local.mkdir(parents=True, exist_ok=True)
|
||||
_walk(child_remote, child_local)
|
||||
else:
|
||||
try:
|
||||
data = _download_file(token, cloud_source, child_remote)
|
||||
child_local.write_bytes(data)
|
||||
report.transferred += 1
|
||||
except graph.Ms365GraphError as exc:
|
||||
report.errors.append(f"{child_remote}: {exc}")
|
||||
|
||||
_walk(root_remote, local_dir)
|
||||
return report
|
||||
|
||||
|
||||
def upload_folder(token: str, cloud_source: Dict[str, str], local_dir: Path) -> SyncReport:
|
||||
"""Đẩy mọi file dưới ``local_dir`` lên đúng đường dẫn tương ứng phía cloud
|
||||
(tạo mới hoặc ghi đè). Một chiều: local thắng cho từng file được duyệt qua.
|
||||
Không xoá file cloud nào đã bị xoá ở local, không phát hiện xung đột."""
|
||||
report = SyncReport()
|
||||
root_remote = cloud_source.get("remote_path", "")
|
||||
local_dir = Path(local_dir)
|
||||
for dirpath, _dirnames, filenames in os.walk(local_dir):
|
||||
rel_dir = Path(dirpath).relative_to(local_dir)
|
||||
for fname in filenames:
|
||||
local_file = Path(dirpath) / fname
|
||||
rel_parts = [] if str(rel_dir) == "." else list(rel_dir.parts)
|
||||
rel_parts.append(fname)
|
||||
child_remote = "/".join(([root_remote] if root_remote else []) + rel_parts)
|
||||
try:
|
||||
data = local_file.read_bytes()
|
||||
_upload_file(token, cloud_source, child_remote, data)
|
||||
report.transferred += 1
|
||||
except graph.Ms365GraphError as exc:
|
||||
if "too large" in str(exc):
|
||||
report.skipped_too_large.append(child_remote)
|
||||
else:
|
||||
report.errors.append(f"{child_remote}: {exc}")
|
||||
except OSError as exc:
|
||||
report.errors.append(f"{child_remote}: {exc}")
|
||||
return report
|
||||
+6
-1
@@ -327,7 +327,12 @@ def run_code(
|
||||
else:
|
||||
emit({"type": "tool_start", "id": tc_id, "name": name})
|
||||
if is_extra and extra_executor is not None:
|
||||
result = extra_executor(name, args)
|
||||
if ctx.block_network and not name.startswith("ms365_local__"):
|
||||
result = {"ok": False, "output": (
|
||||
f"{name}: network access is blocked by the Sandbox Security Layer "
|
||||
'("Block network for agent-run commands" is on in Settings).')}
|
||||
else:
|
||||
result = extra_executor(name, args)
|
||||
else:
|
||||
def on_output(line: str, _id=tc_id, _name=name) -> None:
|
||||
emit({"type": "tool_output", "id": _id, "name": _name, "delta": line})
|
||||
|
||||
+24
-5
@@ -91,6 +91,7 @@ def run_cancellable(
|
||||
on_output: Optional[Callable[[str], None]] = None,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
limits: Optional[Dict[str, float]] = None,
|
||||
isolate_network: bool = False,
|
||||
) -> Tuple[Optional[int], str, bool, bool, bool]:
|
||||
"""Run a subprocess so the Stop button can actually interrupt it.
|
||||
|
||||
@@ -117,17 +118,27 @@ def run_cancellable(
|
||||
a failure to create/assign the job just means the existing taskkill
|
||||
fallback is used, same as before this was added.
|
||||
|
||||
``isolate_network`` runs ``args`` as a shell command that the OS keeps
|
||||
off the network (see ``infrastructure/sandbox/network_isolation.py``);
|
||||
if that isolation cannot be set up the command is NOT run.
|
||||
|
||||
Returns ``(returncode, combined_output, cancelled, timed_out,
|
||||
resource_exceeded)``; on a failure to even launch the process,
|
||||
``returncode`` is ``None`` and the output holds the launch error."""
|
||||
cancel = cancel or (lambda: False)
|
||||
popen_kwargs = {} if sys.platform == "win32" else {"start_new_session": True}
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
args, shell=shell, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, bufsize=1, env=env, **popen_kwargs,
|
||||
)
|
||||
except OSError as exc:
|
||||
if isolate_network:
|
||||
from ..infrastructure.sandbox.network_isolation import spawn_without_network
|
||||
|
||||
command = args if isinstance(args, str) else subprocess.list2cmdline(args)
|
||||
proc = spawn_without_network(command, cwd, env)
|
||||
else:
|
||||
proc = subprocess.Popen(
|
||||
args, shell=shell, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, bufsize=1, env=env, **popen_kwargs,
|
||||
)
|
||||
except (OSError, RuntimeError) as exc: # RuntimeError: NetworkIsolationUnavailable
|
||||
return None, str(exc), False, False, False
|
||||
|
||||
with _active_pids_lock:
|
||||
@@ -266,6 +277,10 @@ def ensure_module(module: str, package: str | None = None):
|
||||
pkg = package or module
|
||||
if pkg in _FAILED or not _can_pip():
|
||||
return None
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
return None # not cached in _FAILED: retried once the network is back
|
||||
ok, _ = pip_install(pkg)
|
||||
if not ok:
|
||||
_FAILED.add(pkg)
|
||||
@@ -339,6 +354,10 @@ def pip_install(package: str, cancel: Optional[CancelFn] = None,
|
||||
name) is NOT retried, since repeating it would just waste time."""
|
||||
if not _can_pip():
|
||||
return False, "This packaged build can't install packages at runtime."
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
return False, network_guard.refusal(f"pip install {package}")
|
||||
exe = python or sys.executable
|
||||
attempt = 0
|
||||
while True:
|
||||
|
||||
+19
-8
@@ -94,17 +94,28 @@ def find_input_files(folder: Path, exts: set[str] | None = None,
|
||||
capped at ``max_files`` (0 = unlimited), ``total_matched`` is the count
|
||||
before that cap, so a caller can report how many were skipped."""
|
||||
exts = exts or INPUT_EXTS
|
||||
# Do not sort an unbounded recursive tree merely to return a small prefix.
|
||||
# The caller receives a stable lexical order for the bounded result, while
|
||||
# traversal stops as soon as the configured file budget is reached.
|
||||
files: list[Path] = []
|
||||
total = 0
|
||||
try:
|
||||
matched = sorted(
|
||||
f for f in folder.rglob("*")
|
||||
if f.is_file()
|
||||
and not any(part.startswith(".") for part in f.relative_to(folder).parts)
|
||||
and f.suffix.lower() in exts
|
||||
)
|
||||
for f in folder.rglob("*"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
try:
|
||||
relative = f.relative_to(folder)
|
||||
except ValueError:
|
||||
continue
|
||||
if any(part.startswith(".") for part in relative.parts) or f.suffix.lower() not in exts:
|
||||
continue
|
||||
total += 1
|
||||
if max_files <= 0 or len(files) < max_files:
|
||||
files.append(f)
|
||||
except OSError:
|
||||
return [], 0
|
||||
files = matched if max_files <= 0 else matched[:max_files]
|
||||
return files, len(matched)
|
||||
files.sort(key=lambda p: str(p).lower())
|
||||
return files, total
|
||||
|
||||
|
||||
def find_soffice() -> str | None:
|
||||
|
||||
@@ -132,8 +132,11 @@ class RestApiConnector:
|
||||
|
||||
def call(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Gọi API theo tham số model đưa ra, đi qua lớp TLS có ghim chứng chỉ nội bộ."""
|
||||
from ..application.network import network_guard
|
||||
from .tls_trust import request_any_method as tls_request
|
||||
|
||||
if network_guard.is_blocked():
|
||||
return {"ok": False, "output": network_guard.refusal(self.display_name)}
|
||||
method = str(args.get("method", "GET")).upper()
|
||||
path = str(args.get("path", "")).lstrip("/")
|
||||
url = urljoin(self.base_url, path)
|
||||
@@ -164,10 +167,13 @@ class RestApiConnector:
|
||||
|
||||
def test_connection(self) -> Tuple[bool, str]:
|
||||
"""Thử kết nối tới endpoint; trả về (thành công, thông điệp)."""
|
||||
from ..application.network import network_guard
|
||||
from .tls_trust import request as tls_request
|
||||
|
||||
if not self.base_url.strip("/"):
|
||||
return False, "No base URL configured."
|
||||
if network_guard.is_blocked():
|
||||
return False, network_guard.refusal(self.display_name)
|
||||
headers = {}
|
||||
if self.api_key:
|
||||
headers[self.auth_header] = (
|
||||
|
||||
+71
-3
@@ -12,16 +12,67 @@ sort by recency. History can live locally or in a OneDrive folder (resolved by
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from ..performance import span
|
||||
|
||||
_LIST_CACHE: dict[tuple[str, str, int], List[Dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def _invalidate_history_cache(directory: Path) -> None:
|
||||
prefix = str(Path(directory).resolve())
|
||||
for key in list(_LIST_CACHE):
|
||||
if key[0] == prefix:
|
||||
_LIST_CACHE.pop(key, None)
|
||||
|
||||
|
||||
def new_session_id() -> str:
|
||||
"""Id phiên mới theo mốc thời gian, chính xác tới mili giây."""
|
||||
return datetime.now().strftime("%Y%m%d-%H%M%S-%f")[:-3]
|
||||
|
||||
|
||||
#: Độ dài mong muốn của một tiêu đề hội thoại, tính bằng ký tự.
|
||||
TITLE_MAX_CHARS = 60
|
||||
#: Số ký tự được phép vượt ``TITLE_MAX_CHARS`` để viết nốt từ đang bị cắt dở.
|
||||
#: Cỡ một từ tiếng Việt — đủ để cứu chữ cuối, không đủ để kéo dài tiêu đề.
|
||||
_TITLE_SLACK = 12
|
||||
|
||||
_KHOANG_TRANG = re.compile(r"\s")
|
||||
|
||||
|
||||
def shorten_title(text: str, limit: int = TITLE_MAX_CHARS) -> str:
|
||||
"""Rút gọn tiêu đề mà KHÔNG cắt vào giữa một từ.
|
||||
|
||||
Cắt cứng ở ký tự thứ ``limit`` đọc rất khó chịu khi mốc đó rơi vào giữa từ:
|
||||
"…tóm tắt từng tệp" thành "…tóm tắt từng tệ…" — trông như lỗi gõ chứ không
|
||||
như một câu bị rút gọn. Nên khi mốc cắt rơi vào giữa từ thì viết nốt từ đó.
|
||||
|
||||
Ba lối ra, theo thứ tự ưu tiên:
|
||||
|
||||
* Viết nốt từ đang dở, nếu chỉ phải vượt thêm tối đa ``_TITLE_SLACK`` ký tự.
|
||||
Viết nốt mà vừa hết chuỗi thì **không** thêm dấu ba chấm — không còn chữ
|
||||
nào bị bỏ thì dấu ba chấm là nói dối.
|
||||
* Từ dài bất thường (đường dẫn, URL) thì lùi về ranh giới từ ngay trước mốc,
|
||||
để một token dài không kéo tiêu đề dài ra tuỳ ý.
|
||||
* Cả tiêu đề chỉ là một từ dài thì đành cắt cứng — không còn ranh giới nào.
|
||||
"""
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
if text[limit].isspace(): # mốc cắt vốn đã nằm giữa hai từ
|
||||
return text[:limit].rstrip() + "…"
|
||||
sau = _KHOANG_TRANG.search(text, limit)
|
||||
het_tu = sau.start() if sau is not None else len(text)
|
||||
if het_tu - limit <= _TITLE_SLACK:
|
||||
return text if het_tu == len(text) else text[:het_tu] + "…"
|
||||
truoc = [m.start() for m in _KHOANG_TRANG.finditer(text, 0, limit)]
|
||||
if truoc:
|
||||
return text[:truoc[-1]] + "…"
|
||||
return text[:limit] + "…"
|
||||
|
||||
|
||||
def derive_title(messages: List[Dict[str, Any]]) -> str:
|
||||
"""Suy tiêu đề hội thoại từ tin nhắn đầu tiên của người dùng.
|
||||
|
||||
@@ -29,8 +80,7 @@ def derive_title(messages: List[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
for m in messages:
|
||||
if m.get("role") == "user" and m.get("content"):
|
||||
text = " ".join(m["content"].split())
|
||||
return text[:60] + ("…" if len(text) > 60 else "")
|
||||
return shorten_title(" ".join(m["content"].split()))
|
||||
return "(empty)"
|
||||
|
||||
|
||||
@@ -78,6 +128,7 @@ def save_conversation(
|
||||
# R06-T02: atomic write - see infrastructure/persistence/json/atomic_write.py.
|
||||
from ..infrastructure.persistence.json.atomic_write import write_json
|
||||
write_json(path, payload)
|
||||
_invalidate_history_cache(directory)
|
||||
return path
|
||||
|
||||
|
||||
@@ -85,6 +136,7 @@ def delete_conversation(path) -> None:
|
||||
"""Xoá file hội thoại; không có thì bỏ qua."""
|
||||
try:
|
||||
Path(path).unlink()
|
||||
_invalidate_history_cache(Path(path).parent)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@@ -96,6 +148,7 @@ def rename_conversation(path, new_title: str) -> None:
|
||||
data = load_conversation(path)
|
||||
data["title"] = new_title
|
||||
write_json(Path(path), data)
|
||||
_invalidate_history_cache(Path(path).parent)
|
||||
|
||||
|
||||
def set_pinned(path, pinned: bool) -> None:
|
||||
@@ -105,6 +158,7 @@ def set_pinned(path, pinned: bool) -> None:
|
||||
data = load_conversation(path)
|
||||
data["pinned"] = bool(pinned)
|
||||
write_json(Path(path), data)
|
||||
_invalidate_history_cache(Path(path).parent)
|
||||
|
||||
|
||||
def load_conversation(path: Path) -> Dict[str, Any]:
|
||||
@@ -197,8 +251,16 @@ def list_conversations(directory: Optional[Path] = None, query: str = "") -> Lis
|
||||
if not directory or not directory.exists():
|
||||
return []
|
||||
q = (query or "").strip().lower()
|
||||
try:
|
||||
cache_key = (str(directory.resolve()), q, directory.stat().st_mtime_ns)
|
||||
except OSError:
|
||||
return []
|
||||
cached = _LIST_CACHE.get(cache_key)
|
||||
if cached is not None:
|
||||
return [dict(item) for item in cached]
|
||||
items: List[Dict[str, Any]] = []
|
||||
for path in directory.glob("*.json"):
|
||||
with span("history.list", query=bool(q)):
|
||||
for path in directory.glob("*.json"):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
@@ -221,4 +283,10 @@ def list_conversations(directory: Optional[Path] = None, query: str = "") -> Lis
|
||||
})
|
||||
# pinned first, then most recent
|
||||
items.sort(key=lambda d: (not d["pinned"], -d["mtime"]))
|
||||
_LIST_CACHE[cache_key] = [dict(item) for item in items]
|
||||
# Keep this bounded; old directory signatures become unreachable after a
|
||||
# write and should not grow process memory forever.
|
||||
if len(_LIST_CACHE) > 256:
|
||||
for old in list(_LIST_CACHE)[:64]:
|
||||
_LIST_CACHE.pop(old, None)
|
||||
return items
|
||||
|
||||
@@ -85,8 +85,10 @@ def get_issue_by_url(config: Dict[str, Any] | None, url: str) -> str:
|
||||
|
||||
def _get(config: Dict[str, Any], path: str, params: dict = None):
|
||||
"""Gọi Jira REST API bằng xác thực cơ bản, qua lớp TLS có ghim chứng chỉ nội bộ."""
|
||||
from ..application.network import network_guard
|
||||
from . import tls_trust
|
||||
|
||||
network_guard.ensure_allowed("Jira")
|
||||
c = _conf(config)
|
||||
url = c["base_url"].rstrip("/") + path
|
||||
# Same TLS auto-recovery the LLM provider calls get (core/tls_trust.py) —
|
||||
|
||||
@@ -147,6 +147,12 @@ def fetch_link_preview(url: str) -> str:
|
||||
return ""
|
||||
if not re.match(r"^https?://", url, re.IGNORECASE):
|
||||
return f"[Link: {url}] (not a fetchable http(s) URL — referenced by address only)"
|
||||
# Every caller (fetch_url, task link attachments, ...) passes through here,
|
||||
# so this one check covers the paths that never saw a ToolContext.
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
return f"[Link: {url}] (not fetched — {network_guard.refusal('link fetch')})"
|
||||
# SharePoint / OneDrive share links are rewritten to their direct-download
|
||||
# form so the shared FILE itself is fetched and parsed (like an attachment),
|
||||
# not the share page's HTML shell.
|
||||
|
||||
+12
-1
@@ -88,7 +88,14 @@ class McpServerConnection:
|
||||
def start(self, timeout: float = 15.0) -> None:
|
||||
"""Spawn the server subprocess and complete the MCP handshake.
|
||||
Raises :class:`McpServerError` on failure (bad command, the server
|
||||
crashed on startup, the handshake timed out, ...)."""
|
||||
crashed on startup, the handshake timed out, ...).
|
||||
|
||||
Refused while "Block network" is on: a server process is free to open
|
||||
any socket it likes, so the only safe server is one never started."""
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
raise McpServerError(network_guard.refusal(f"MCP server '{self.name}'"))
|
||||
self._thread = threading.Thread(target=self._run_loop, daemon=True)
|
||||
self._thread.start()
|
||||
if not self._ready.wait(timeout):
|
||||
@@ -174,6 +181,10 @@ class McpServerConnection:
|
||||
|
||||
def call_tool(self, qualified_name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""``extra_executor``-shaped result: ``{"ok": bool, "output": str}``."""
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
return {"ok": False, "output": network_guard.refusal(f"MCP server '{self.name}'")}
|
||||
tool_name = qualified_name.split(_SEP, 1)[1] if _SEP in qualified_name else qualified_name
|
||||
try:
|
||||
result = self._run_coro(self._session.call_tool(tool_name, args or {}))
|
||||
|
||||
@@ -137,8 +137,34 @@ def _app(tenant_id: str, client_id: str):
|
||||
return app, cache
|
||||
|
||||
|
||||
def _cached_account_offline() -> Optional[dict]:
|
||||
"""First account in the saved token cache, read without any MSAL network setup."""
|
||||
try:
|
||||
import msal
|
||||
|
||||
accounts = _load_cache().find(msal.TokenCache.CredentialType.ACCOUNT)
|
||||
except Exception: # noqa: BLE001 - no msal / unreadable cache = not signed in
|
||||
return None
|
||||
return accounts[0] if accounts else None
|
||||
|
||||
|
||||
def _ensure_network(action: str) -> None:
|
||||
"""Turn a "Block network" refusal into the error type callers already handle."""
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
raise Ms365AuthError(network_guard.refusal(action))
|
||||
|
||||
|
||||
def signed_in_account(tenant_id: str, client_id: str) -> Optional[dict]:
|
||||
"""The cached account, if any — a local cache lookup, no network call."""
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
# Building the MSAL app fetches the tenant's OpenID configuration, so
|
||||
# read the token cache directly instead: the UI still sees who is
|
||||
# signed in without the app reaching login.microsoftonline.com.
|
||||
return _cached_account_offline()
|
||||
try:
|
||||
app, _cache = _app(tenant_id, client_id)
|
||||
except Ms365AuthError:
|
||||
@@ -156,6 +182,7 @@ def sign_in_device_code(tenant_id: str, client_id: str, on_code: Callable[[dict]
|
||||
``verification_uri_complete`` (URL with the code pre-filled, when the tenant
|
||||
returns it) and ``message`` (the full human-readable instruction). Returns
|
||||
the MSAL token result dict; raises Ms365AuthError on failure/timeout."""
|
||||
_ensure_network("Microsoft 365 sign-in")
|
||||
app, cache = _app(tenant_id, client_id)
|
||||
flow = app.initiate_device_flow(scopes=SCOPES)
|
||||
if "user_code" not in flow:
|
||||
@@ -183,6 +210,7 @@ def get_access_token(tenant_id: str, client_id: str) -> str:
|
||||
"""Silently reuse the cached sign-in. Raises Ms365AuthError when there is
|
||||
no valid session — the caller (a Graph call) should surface that as a
|
||||
normal tool failure telling the user to sign in again from Settings."""
|
||||
_ensure_network("Microsoft 365")
|
||||
app, cache = _app(tenant_id, client_id)
|
||||
accounts = app.get_accounts()
|
||||
if not accounts:
|
||||
@@ -228,6 +256,7 @@ def sign_out_default(config=None) -> None:
|
||||
def sign_out(tenant_id: str, client_id: str) -> None:
|
||||
"""Đăng xuất và xoá token của một tenant/client khỏi kho."""
|
||||
try:
|
||||
_ensure_network("Microsoft 365 sign-out") # chặn mạng: chỉ xoá kho token bên dưới
|
||||
app, cache = _app(tenant_id, client_id)
|
||||
for acc in app.get_accounts():
|
||||
app.remove_account(acc)
|
||||
|
||||
@@ -43,6 +43,10 @@ def _request(method: str, url: str, token: str, **kwargs) -> requests.Response:
|
||||
"""Gọi Graph API, tự ghép ``GRAPH_BASE`` cho đường dẫn tương đối và đổi lỗi HTTP
|
||||
thành :class:`Ms365GraphError` kèm thông điệp đọc được.
|
||||
"""
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
raise Ms365GraphError(network_guard.refusal("Microsoft 365 (Graph)"))
|
||||
if not url.startswith("http"):
|
||||
url = f"{GRAPH_BASE}{url}"
|
||||
headers = _headers(token, kwargs.pop("headers", None))
|
||||
@@ -196,6 +200,40 @@ def write_onedrive_file(token: str, path: str, content: str) -> dict:
|
||||
return resp.json()
|
||||
|
||||
|
||||
# Graph's "simple upload" (a single PUT to .../content) is documented to only
|
||||
# support items up to 4 MiB; anything larger needs a chunked "upload session"
|
||||
# (createUploadSession + PUT-per-range), which this module does not implement
|
||||
# (see DF-007 cloud workspace picker — v1 explicitly skips large files rather
|
||||
# than silently truncating or corrupting them).
|
||||
MAX_SIMPLE_UPLOAD_BYTES = 4 * 1024 * 1024
|
||||
|
||||
|
||||
def _check_upload_size(data: bytes) -> None:
|
||||
if len(data) > MAX_SIMPLE_UPLOAD_BYTES:
|
||||
raise Ms365GraphError(
|
||||
f"File too large for simple upload ({len(data)} bytes > "
|
||||
f"{MAX_SIMPLE_UPLOAD_BYTES} bytes) — chunked upload sessions are not "
|
||||
"implemented yet."
|
||||
)
|
||||
|
||||
|
||||
def download_onedrive_file_bytes(token: str, path: str) -> bytes:
|
||||
"""Đọc RAW BYTES một tệp OneDrive (không ép UTF-8/không cắt) — dùng cho
|
||||
mirror thư mục cloud xuống local, khác với :func:`read_onedrive_file` vốn
|
||||
chỉ dành cho việc đọc nội dung văn bản vào ngữ cảnh chat."""
|
||||
resp = _request("GET", f"/me/drive/root:/{_path_segment(path)}:/content", token)
|
||||
return resp.content
|
||||
|
||||
|
||||
def upload_onedrive_file_bytes(token: str, path: str, data: bytes) -> dict:
|
||||
"""Ghi RAW BYTES vào một tệp OneDrive (tạo mới hoặc ghi đè). Xem
|
||||
:data:`MAX_SIMPLE_UPLOAD_BYTES`."""
|
||||
_check_upload_size(data)
|
||||
resp = _request("PUT", f"/me/drive/root:/{_path_segment(path)}:/content", token,
|
||||
data=data, headers={"Content-Type": "application/octet-stream"})
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _encode_share_url(url: str) -> str:
|
||||
"""Encode a OneDrive/SharePoint sharing URL into Graph's ``u!<base64url>``
|
||||
share-id form (see Microsoft's 'Get access to shared items' docs)."""
|
||||
@@ -229,6 +267,24 @@ def list_sharepoint_files(token: str, site_id: str, path: str = "") -> List[dict
|
||||
return resp.json().get("value", [])
|
||||
|
||||
|
||||
def download_sharepoint_file_bytes(token: str, site_id: str, path: str) -> bytes:
|
||||
"""Đọc RAW BYTES một tệp trong thư viện tài liệu SharePoint — xem
|
||||
:func:`download_onedrive_file_bytes`."""
|
||||
resp = _request(
|
||||
"GET", f"/sites/{quote(site_id)}/drive/root:/{_path_segment(path)}:/content", token)
|
||||
return resp.content
|
||||
|
||||
|
||||
def upload_sharepoint_file_bytes(token: str, site_id: str, path: str, data: bytes) -> dict:
|
||||
"""Ghi RAW BYTES vào một tệp trong thư viện tài liệu SharePoint. Xem
|
||||
:data:`MAX_SIMPLE_UPLOAD_BYTES`."""
|
||||
_check_upload_size(data)
|
||||
resp = _request(
|
||||
"PUT", f"/sites/{quote(site_id)}/drive/root:/{_path_segment(path)}:/content", token,
|
||||
data=data, headers={"Content-Type": "application/octet-stream"})
|
||||
return resp.json()
|
||||
|
||||
|
||||
# ---- Teams meeting transcripts ------------------------------------------
|
||||
def find_online_meeting(token: str, join_url: str) -> List[dict]:
|
||||
"""Tìm cuộc họp online theo link tham gia."""
|
||||
|
||||
@@ -24,6 +24,7 @@ project — nothing about it is special-cased in the UI.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime
|
||||
@@ -65,6 +66,13 @@ class Project:
|
||||
# auto_run: None → follow the global agent_security.cowork_confirm_commands;
|
||||
# True → auto-approve commands (no confirm); False → always confirm.
|
||||
auto_run: Optional[bool] = None
|
||||
# {} = an ordinary local/managed workspace. Non-empty when ``output_dir``
|
||||
# is a LOCAL MIRROR of a OneDrive/SharePoint folder (see
|
||||
# core/cloud_workspace_sync.py) — {"provider": "onedrive"|"sharepoint",
|
||||
# "site_id": "", "site_name": "", "remote_path": ""}. ``output_dir`` itself
|
||||
# always stays a real local path; nothing that reads ``workspace_dir()``
|
||||
# needs to change because of this field.
|
||||
cloud_source: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def workspace_dir(self, base: Path = None) -> Path:
|
||||
"""The project's sandbox root. Every chat of the project writes inside
|
||||
@@ -75,6 +83,53 @@ class Project:
|
||||
return (base or WORKSPACES_DIR) / self.project_id
|
||||
|
||||
|
||||
def _norm_dir(path) -> str:
|
||||
"""Đường dẫn đã chuẩn hoá để đem ra so sánh.
|
||||
|
||||
Bung ``~``, đưa về tuyệt đối, rồi ``normcase`` — trên Windows thì
|
||||
``D:/Work`` và ``d:/work`` là cùng một thư mục, nên so chuỗi thô sẽ
|
||||
cho hai project chiếm chung một chỗ mà không ai biết.
|
||||
"""
|
||||
return os.path.normcase(os.path.abspath(os.path.expanduser(str(path))))
|
||||
|
||||
|
||||
def _cham_nhau(a: str, b: str) -> bool:
|
||||
"""Hai thư mục đã chuẩn hoá có chạm nhau không: trùng, hoặc lồng nhau.
|
||||
|
||||
Lồng nhau cũng tính, vì lý do tồn tại của sandbox là "agent của project này
|
||||
không bao giờ chạm được file của project kia" (xem docstring đầu module).
|
||||
Đứng ở thư mục cha thì đọc/ghi được toàn bộ thư mục con, nên cha-con vẫn là
|
||||
chạm nhau dù hai đường dẫn không giống nhau.
|
||||
"""
|
||||
return a == b or a.startswith(b + os.sep) or b.startswith(a + os.sep)
|
||||
|
||||
|
||||
def folder_conflict(path, *, ignore_id: str = "",
|
||||
directory: Path = None) -> Optional[Project]:
|
||||
"""Project khác đang chiếm ``path``, hoặc ``None`` nếu chưa ai chiếm.
|
||||
|
||||
Mỗi thư mục chỉ được thuộc về một project: thư mục làm việc vừa là sandbox
|
||||
vừa là kho kiến thức dùng chung của project, nên hai project dùng chung một
|
||||
thư mục là đọc lẫn dữ liệu của nhau.
|
||||
|
||||
So theo thư mục THỰC SỰ đang dùng (``workspace_dir()``), không phải theo
|
||||
``output_dir``: project chưa đặt thư mục riêng vẫn đang chiếm thư mục quản
|
||||
lý sẵn của nó, và chính thư mục đó là thứ hay bị chọn nhầm.
|
||||
|
||||
``ignore_id`` là project đang sửa — giữ nguyên thư mục của chính nó thì
|
||||
không phải là trùng.
|
||||
"""
|
||||
if not str(path).strip():
|
||||
return None
|
||||
muon = _norm_dir(path)
|
||||
for project in list_projects(directory):
|
||||
if project.project_id == ignore_id:
|
||||
continue
|
||||
if _cham_nhau(muon, _norm_dir(project.workspace_dir())):
|
||||
return project
|
||||
return None
|
||||
|
||||
|
||||
def _starter_project() -> Project:
|
||||
"""An ordinary (deletable, renamable) project seeded when the projects
|
||||
folder is empty, so the app always opens with somewhere to chat."""
|
||||
|
||||
+48
-4
@@ -218,8 +218,13 @@ class SandboxManager:
|
||||
timeout_sec: int,
|
||||
cancel: Optional[Callable[[], bool]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Dispatch execution to the selected backend."""
|
||||
if backend == "direct":
|
||||
"""Dispatch execution to the selected backend.
|
||||
|
||||
With the network blocked every backend is replaced by the same OS-level
|
||||
isolation: the backends below only ever set proxy env vars, which
|
||||
anything that ignores proxies (raw sockets, ping, .NET WebClient...)
|
||||
walked straight past."""
|
||||
if block_network or backend == "direct":
|
||||
return self._run_direct(command, workdir, block_network, timeout_sec, cancel)
|
||||
|
||||
if backend == "integrity_job_wfp":
|
||||
@@ -274,7 +279,8 @@ class SandboxManager:
|
||||
env = os.environ.copy()
|
||||
if block_network:
|
||||
from .deps import network_blocked_env
|
||||
env = network_blocked_env(env)
|
||||
env = network_blocked_env(env) # belt and braces on top of the OS block
|
||||
return self._run_network_isolated(command, workdir, env, timeout_sec, cancel)
|
||||
|
||||
if cancel is not None:
|
||||
from .deps import run_cancellable
|
||||
@@ -334,4 +340,42 @@ class SandboxManager:
|
||||
"stderr": str(exc),
|
||||
"returncode": -1,
|
||||
"sandbox": "direct",
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _run_network_isolated(
|
||||
command: str,
|
||||
workdir: str,
|
||||
env: Dict[str, str],
|
||||
timeout_sec: int,
|
||||
cancel: Optional[Callable[[], bool]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run ``command`` in a process the OS keeps off the network.
|
||||
|
||||
Fail-closed: when the isolation cannot be set up the command is
|
||||
refused (``sandbox == "blocked"``), never run with the network open."""
|
||||
from .deps import run_cancellable
|
||||
|
||||
rc, output, cancelled, timed_out, exceeded = run_cancellable(
|
||||
command, cwd=workdir or None, timeout=timeout_sec, cancel=cancel,
|
||||
shell=True, env=env, isolate_network=True,
|
||||
)
|
||||
if rc is None and not (cancelled or timed_out or exceeded):
|
||||
return {"ok": False, "stdout": "", "returncode": -1, "sandbox": "blocked",
|
||||
"stderr": ("Command refused: network is blocked and the command could "
|
||||
f"not be isolated from the network ({output.strip()}).")}
|
||||
if cancelled:
|
||||
stderr = "Cancelled by user."
|
||||
elif timed_out:
|
||||
stderr = f"Timeout after {timeout_sec}s"
|
||||
elif exceeded:
|
||||
stderr = "Resource limit exceeded."
|
||||
else:
|
||||
stderr = ""
|
||||
return {
|
||||
"ok": rc == 0 and not (cancelled or timed_out or exceeded),
|
||||
"stdout": output,
|
||||
"stderr": stderr,
|
||||
"returncode": rc if rc is not None else -1,
|
||||
"sandbox": "network_isolated",
|
||||
}
|
||||
|
||||
+1
-13
@@ -19,7 +19,6 @@ in Waiting Input), so executors here run with an auto gate.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
@@ -30,6 +29,7 @@ from . import agent_roles
|
||||
from . import agent_security
|
||||
from . import projects
|
||||
from .permissions import PermissionGate
|
||||
from .task_script import run_script as _run_script
|
||||
from .tasks import ARTIFACTS_DIR, resolve_input_text
|
||||
from .tools import ToolContext
|
||||
|
||||
@@ -358,18 +358,6 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
|
||||
return _last_assistant_text(messages), timed_out(), incomplete
|
||||
|
||||
|
||||
def _run_script(command: str, out_dir: Path, timeout_sec: int) -> str:
|
||||
"""Chạy một task kiểu script bằng shell trong thư mục kết quả, có hạn giờ."""
|
||||
if not command.strip():
|
||||
raise RuntimeError("Script task has no command configured.")
|
||||
proc = subprocess.run(command, shell=True, cwd=str(out_dir),
|
||||
capture_output=True, text=True, timeout=max(1, timeout_sec))
|
||||
output = (proc.stdout or "") + (("\n[stderr]\n" + proc.stderr) if proc.stderr else "")
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"Script exited with code {proc.returncode}:\n{output[-2000:]}")
|
||||
return output
|
||||
|
||||
|
||||
def execute_task(ctx, task: Dict[str, Any], run_id: str,
|
||||
emit: Optional[EmitFn] = None, cancel: Optional[CancelFn] = None,
|
||||
tasks_dir: Path = None) -> Dict[str, Any]:
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Run a scheduled task of type ``script`` (tách khỏi ``task_executors.py``).
|
||||
|
||||
Khi công tắc "Chặn mạng" đang bật, lệnh của task chạy trong tiến trình bị hệ
|
||||
điều hành cắt mạng — giống ``run_command`` của agent. Trước đây task script
|
||||
chạy thẳng bằng ``subprocess.run``, không sandbox, nên lên mạng tự do.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_script(command: str, out_dir: Path, timeout_sec: int) -> str:
|
||||
"""Chạy một task kiểu script bằng shell trong thư mục kết quả, có hạn giờ."""
|
||||
if not command.strip():
|
||||
raise RuntimeError("Script task has no command configured.")
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
return _run_script_without_network(command, out_dir, timeout_sec)
|
||||
proc = subprocess.run(command, shell=True, cwd=str(out_dir),
|
||||
capture_output=True, text=True, timeout=max(1, timeout_sec))
|
||||
output = (proc.stdout or "") + (("\n[stderr]\n" + proc.stderr) if proc.stderr else "")
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"Script exited with code {proc.returncode}:\n{output[-2000:]}")
|
||||
return output
|
||||
|
||||
|
||||
def _run_script_without_network(command: str, out_dir: Path, timeout_sec: int) -> str:
|
||||
"""Như :func:`run_script`, nhưng tiến trình không có mạng; không cô lập được thì không chạy."""
|
||||
from .deps import network_blocked_env, run_cancellable
|
||||
|
||||
rc, output, _cancelled, timed_out, _exceeded = run_cancellable(
|
||||
command, cwd=str(out_dir), timeout=max(1, timeout_sec), shell=True,
|
||||
env=network_blocked_env(), isolate_network=True,
|
||||
)
|
||||
if timed_out:
|
||||
raise subprocess.TimeoutExpired(command, timeout_sec)
|
||||
if rc is None:
|
||||
raise RuntimeError("Script not run: network is blocked and the script could not be "
|
||||
f"isolated from the network ({output.strip()}).")
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Script exited with code {rc} (network blocked):\n{output[-2000:]}")
|
||||
return output
|
||||
|
||||
|
||||
__all__ = ["run_script"]
|
||||
+13
-8
@@ -258,9 +258,13 @@ def dependencies_met(task: Dict[str, Any], directory: Path = None) -> bool:
|
||||
def depends_cycle_error(tasks: List[Dict[str, Any]], task_id: str,
|
||||
depends_on: List[str]) -> Optional[str]:
|
||||
"""Validate a proposed depends_on list: no self-wait, no wait-cycle
|
||||
(A waits B while B — directly or transitively — waits A)."""
|
||||
(A waits B while B — directly or transitively — waits A).
|
||||
|
||||
Trả về KHOÁ i18n chứ không phải câu đã dịch: tầng này không biết người dùng
|
||||
đang chọn ngôn ngữ nào, nên nơi hiển thị mới là nơi gọi ``tr()``.
|
||||
"""
|
||||
if task_id in (depends_on or []):
|
||||
return "A task cannot wait for itself."
|
||||
return "schedtask.err_self_wait"
|
||||
by_id = {t["task_id"]: t for t in tasks}
|
||||
# DFS from each proposed prerequisite through ITS prerequisites.
|
||||
for start in depends_on or []:
|
||||
@@ -268,7 +272,7 @@ def depends_cycle_error(tasks: List[Dict[str, Any]], task_id: str,
|
||||
while stack:
|
||||
cur = stack.pop()
|
||||
if cur == task_id:
|
||||
return "This would create a circular wait between tasks."
|
||||
return "schedtask.err_wait_cycle"
|
||||
if cur in seen:
|
||||
continue
|
||||
seen.add(cur)
|
||||
@@ -280,22 +284,23 @@ def depends_cycle_error(tasks: List[Dict[str, Any]], task_id: str,
|
||||
def chain_error(tasks: List[Dict[str, Any]], task_id: str,
|
||||
next_task_id: Optional[str]) -> Optional[str]:
|
||||
"""Validate assigning ``next_task_id`` as ``task_id``'s next task.
|
||||
Returns an error string (self-link / circular chain / unknown id), or
|
||||
None when the assignment is safe."""
|
||||
Returns an i18n KEY for the problem (self-link / circular chain / unknown
|
||||
id), or None when the assignment is safe. Khoá chứ không phải câu đã dịch —
|
||||
xem ``depends_cycle_error``."""
|
||||
if not next_task_id:
|
||||
return None
|
||||
if next_task_id == task_id:
|
||||
return "A task cannot chain to itself."
|
||||
return "schedtask.err_self_chain"
|
||||
by_id = {t["task_id"]: t for t in tasks}
|
||||
if next_task_id not in by_id:
|
||||
return "Next task does not exist."
|
||||
return "schedtask.err_next_missing"
|
||||
# Walk forward from the proposed next task; reaching task_id again means
|
||||
# the new edge would close a cycle.
|
||||
seen = {task_id}
|
||||
cur = next_task_id
|
||||
while cur:
|
||||
if cur in seen:
|
||||
return "This would create a circular task chain."
|
||||
return "schedtask.err_chain_cycle"
|
||||
seen.add(cur)
|
||||
cur = (by_id.get(cur) or {}).get("dependency", {}).get("next_task_id")
|
||||
return None
|
||||
|
||||
@@ -43,6 +43,10 @@ class TeamsNotifier:
|
||||
"""Post a notification. Returns ``(ok, detail)``."""
|
||||
if not self.configured:
|
||||
return False, "Teams webhook URL is not configured."
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
return False, network_guard.refusal("Teams notification")
|
||||
|
||||
# Workflows webhooks expect an Adaptive Card; classic connectors expect a
|
||||
# MessageCard. Try both, then a plain-text fallback.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
Source HEAD: db80289 (preserved)
|
||||
Branch: perf/fsg-performance
|
||||
|
||||
Baseline: app import 1517ms; config 934ms; MainWindow 1742ms (offscreen, local machine).
|
||||
|
||||
Packets completed:
|
||||
- P0: measured constructor with cProfile; dominant cost was provider model discovery (~0.7s network worker) and eager Workspace composition.
|
||||
- P2: cache history listing by directory mtime/query and coalesce sidebar refresh bursts.
|
||||
- P3: batch streaming Markdown/layout renders at 40ms; final content remains intact.
|
||||
- P4: bounded attachment discovery avoids sorting a full recursive tree when a cap is set.
|
||||
- P5: instrument monitoring log refresh; existing 30-day bounded window retained.
|
||||
- P6: defer provider model discovery to the first Qt event-loop turn.
|
||||
|
||||
After: config 452ms; MainWindow 599ms in the same offscreen smoke benchmark (discovery no longer blocks construction).
|
||||
Streaming render count is now bounded by batch cadence rather than token count.
|
||||
Representative history benchmark: 1,000 files 383.6ms cold / 0.8ms cached on this machine.
|
||||
|
||||
Relevant commits: c5cb258 (perf: defer discovery and reduce UI refresh work).
|
||||
Remaining bottleneck: eager Workspace/Co4E/Folder widget construction and import-time PySide6 overhead.
|
||||
|
||||
Closure pass (starting HEAD 58b5220): Workspace now keeps Co4E, Folder, and GraphRAG as tab placeholders and creates each once on first selection. MainWindow benchmark: 357.6ms; first opens Co4E 143.1ms, Folder 148.0ms, GraphRAG 270.6ms; repeat opens 0.0–2.4ms. Focused lazy navigation/project tests: 15 passed. Pytest temp failures were ACL/path setup issues, not production assertions; a pre-created writable repository-local temp base allowed the focused gates to pass.
|
||||
Remaining startup cost is base PySide6/application import and eager Cowork shell; further lazy work is not justified without broader architectural risk.
|
||||
Performance initiative status: closed for this pass.
|
||||
@@ -48,6 +48,9 @@ from . import skills_dialog as _skills_dialog
|
||||
from . import libreoffice_view as _libreoffice_view
|
||||
from . import agents_admin_tab as _agents_admin_tab
|
||||
from . import monitoring_overview as _monitoring_overview
|
||||
from . import cloud_workspace as _cloud_workspace
|
||||
from . import dialog_buttons as _dialog_buttons
|
||||
from . import connectors as _connectors
|
||||
|
||||
# Gộp theo đúng thứ tự cũ: khoá trùng thì cụm sau thắng, y như khi tất cả
|
||||
# còn nằm chung một dict literal.
|
||||
@@ -62,6 +65,9 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
**_libreoffice_view.STRINGS,
|
||||
**_agents_admin_tab.STRINGS,
|
||||
**_monitoring_overview.STRINGS,
|
||||
**_cloud_workspace.STRINGS,
|
||||
**_dialog_buttons.STRINGS,
|
||||
**_connectors.STRINGS,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -232,6 +232,14 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"ja": "行をフィルター(質問を入力しても可)…",
|
||||
"vi": "Lọc dòng (hoặc gõ câu hỏi rồi bấm )…"},
|
||||
"monitoring.ai_filter_btn": {"en": "AI", "ja": "AI", "vi": "AI"},
|
||||
"monitoring.page_size_label": {
|
||||
"en": "Rows/page:", "ja": "1ページの行数:", "vi": "Số dòng/trang:"},
|
||||
"monitoring.page_indicator": {
|
||||
"en": "Page {page}/{total}", "ja": "{page}/{total} ページ", "vi": "Trang {page}/{total}"},
|
||||
"monitoring.page_prev": {
|
||||
"en": "Previous page", "ja": "前のページ", "vi": "Trang trước"},
|
||||
"monitoring.page_next": {
|
||||
"en": "Next page", "ja": "次のページ", "vi": "Trang sau"},
|
||||
"monitoring.pricing_title": {
|
||||
"en": "Model pricing (USD / 1M tokens)", "ja": "モデル価格表 (USD / 100万トークン)",
|
||||
"vi": "Bảng giá model (USD / 1 triệu token)"},
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""DF-007 — Microsoft 365 sign-in dialog + cloud (OneDrive/SharePoint)
|
||||
folder picker. Deliberately its own module rather than reusing the
|
||||
similarly-named orphaned keys under ``settings.ms365_*`` in ``cowork_tab.py``/
|
||||
``settings_dialog.py`` — those are leftovers from a MS365 sign-in UI that was
|
||||
removed (see ``ui/settings_dialog.py`` module docstring) and the two files
|
||||
disagree with each other on wording for several duplicate keys, so reusing
|
||||
them risked resurrecting an inconsistency rather than a clean, tested string
|
||||
set."""
|
||||
from __future__ import annotations
|
||||
|
||||
STRINGS = {
|
||||
# ---- ui/ms365_signin_dialog.py ----
|
||||
"ms365_signin.title": {
|
||||
"en": "Sign in to Microsoft 365", "ja": "Microsoft 365 にサインイン",
|
||||
"vi": "Đăng nhập Microsoft 365",
|
||||
},
|
||||
"ms365_signin.already": {
|
||||
"en": "Signed in as {who}.", "ja": "{who} としてサインイン済みです。",
|
||||
"vi": "Đã đăng nhập với {who}.",
|
||||
},
|
||||
"ms365_signin.intro": {
|
||||
"en": "Sign in with your Microsoft work/school (or personal) account to "
|
||||
"browse OneDrive/SharePoint folders.",
|
||||
"ja": "OneDrive/SharePoint のフォルダーを参照するには、Microsoft の職場/学校\n"
|
||||
"(または個人) アカウントでサインインしてください。",
|
||||
"vi": "Đăng nhập bằng tài khoản Microsoft (công ty/trường học hoặc cá nhân) "
|
||||
"để duyệt thư mục OneDrive/SharePoint.",
|
||||
},
|
||||
"ms365_signin.button": {
|
||||
"en": "Sign in", "ja": "サインイン", "vi": "Đăng nhập",
|
||||
},
|
||||
"ms365_signin.signing_in": {
|
||||
"en": "Signing in…", "ja": "サインイン中…", "vi": "Đang đăng nhập…",
|
||||
},
|
||||
"ms365_signin.code_hint": {
|
||||
"en": "Open {url} and enter this code:", "ja": "{url} を開いてこのコードを入力してください:",
|
||||
"vi": "Mở {url} và nhập mã sau:",
|
||||
},
|
||||
"ms365_signin.open_link": {
|
||||
"en": "Open link", "ja": "リンクを開く", "vi": "Mở link",
|
||||
},
|
||||
"ms365_signin.failed": {
|
||||
"en": "Sign-in failed: {err}", "ja": "サインインに失敗しました: {err}",
|
||||
"vi": "Đăng nhập thất bại: {err}",
|
||||
},
|
||||
"ms365_signin.cancel": {
|
||||
"en": "Cancel", "ja": "キャンセル", "vi": "Hủy",
|
||||
},
|
||||
# ---- ui/cloud_folder_picker_dialog.py ----
|
||||
"cloud_picker.title": {
|
||||
"en": "Choose a OneDrive/SharePoint folder", "ja": "OneDrive/SharePoint フォルダーを選択",
|
||||
"vi": "Chọn thư mục OneDrive/SharePoint",
|
||||
},
|
||||
"cloud_picker.source_onedrive": {
|
||||
"en": "My OneDrive", "ja": "自分の OneDrive", "vi": "OneDrive của tôi",
|
||||
},
|
||||
"cloud_picker.source_sharepoint": {
|
||||
"en": "SharePoint site", "ja": "SharePoint サイト", "vi": "Site SharePoint",
|
||||
},
|
||||
"cloud_picker.search_sites_placeholder": {
|
||||
"en": "Search SharePoint sites…", "ja": "SharePoint サイトを検索…",
|
||||
"vi": "Tìm site SharePoint…",
|
||||
},
|
||||
"cloud_picker.search_btn": {
|
||||
"en": "Search", "ja": "検索", "vi": "Tìm",
|
||||
},
|
||||
"cloud_picker.up": {
|
||||
"en": ".. (up)", "ja": ".. (上へ)", "vi": ".. (lùi lại)",
|
||||
},
|
||||
"cloud_picker.choose_here": {
|
||||
"en": "Choose this folder", "ja": "このフォルダーを選択", "vi": "Chọn thư mục này",
|
||||
},
|
||||
"cloud_picker.cancel": {
|
||||
"en": "Cancel", "ja": "キャンセル", "vi": "Hủy",
|
||||
},
|
||||
"cloud_picker.load_failed": {
|
||||
"en": "Could not load this folder: {err}", "ja": "フォルダーを読み込めませんでした: {err}",
|
||||
"vi": "Không tải được thư mục này: {err}",
|
||||
},
|
||||
"cloud_picker.no_sites": {
|
||||
"en": "No matching SharePoint sites.", "ja": "一致する SharePoint サイトがありません。",
|
||||
"vi": "Không tìm thấy site SharePoint phù hợp.",
|
||||
},
|
||||
# ---- ui/workspace_tab.py additions ----
|
||||
"workspace.cloud_pick": {
|
||||
"en": "Choose from OneDrive/SharePoint…", "ja": "OneDrive/SharePoint から選択…",
|
||||
"vi": "Chọn từ OneDrive/SharePoint…",
|
||||
},
|
||||
"workspace.cloud_sync": {
|
||||
"en": "Sync with cloud", "ja": "クラウドと同期", "vi": "Đồng bộ với cloud",
|
||||
},
|
||||
"workspace.cloud_badge_onedrive": {
|
||||
"en": "☁ Local mirror of OneDrive: {path}", "ja": "☁ OneDrive のローカルミラー: {path}",
|
||||
"vi": "☁ Bản sao cục bộ của OneDrive: {path}",
|
||||
},
|
||||
"workspace.cloud_badge_sharepoint": {
|
||||
"en": "☁ Local mirror of SharePoint ({site}): {path}",
|
||||
"ja": "☁ SharePoint ({site}) のローカルミラー: {path}",
|
||||
"vi": "☁ Bản sao cục bộ của SharePoint ({site}): {path}",
|
||||
},
|
||||
"workspace.cloud_sync_result": {
|
||||
"en": "Sync done — {up} uploaded, {down} downloaded.",
|
||||
"ja": "同期完了 — アップロード {up} 件、ダウンロード {down} 件。",
|
||||
"vi": "Đồng bộ xong — {up} tệp đẩy lên, {down} tệp tải về.",
|
||||
},
|
||||
"workspace.cloud_sync_errors": {
|
||||
"en": "{n} item(s) had errors — see details below.",
|
||||
"ja": "{n} 件のエラーがありました — 詳細は下記のとおりです。",
|
||||
"vi": "{n} mục bị lỗi — chi tiết bên dưới.",
|
||||
},
|
||||
"workspace.cloud_sync_skipped": {
|
||||
"en": "{n} file(s) skipped (over 4 MB, not supported yet).",
|
||||
"ja": "{n} 件のファイルはスキップされました (4 MB 超、未対応)。",
|
||||
"vi": "{n} tệp bị bỏ qua (quá 4 MB, chưa hỗ trợ).",
|
||||
},
|
||||
}
|
||||
@@ -114,6 +114,8 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"chatpanel.working": {"en": "{name}: working…", "ja": "{name}: 処理中…", "vi": "{name}: đang xử lý…"},
|
||||
"chatpanel.done": {"en": "{name}: done.", "ja": "{name}: 完了。", "vi": "{name}: xong."},
|
||||
"chatpanel.failed": {"en": "{name}: error.", "ja": "{name}: エラー。", "vi": "{name}: lỗi."},
|
||||
"chatpanel.stopped": {"en": "{name}: stopped.", "ja": "{name}: 停止しました。",
|
||||
"vi": "{name}: đã dừng."},
|
||||
"chatpanel.stopping": {"en": "{name}: stopping…", "ja": "{name}: 停止中…", "vi": "{name}: đang dừng…"},
|
||||
"chatpanel.attach_limit": {
|
||||
"en": "Max {n} attachments — extra files were skipped.",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Chuỗi hiển thị — nhóm Connector (Giám sát ▸ Công cụ ▸ Connector).
|
||||
|
||||
Tên bốn nhóm catalog trên bảng Connector. Đứng riêng một file vì
|
||||
``libreoffice_view.py`` — nơi giữ các khoá ``connectors.*`` cũ — đã sát trần
|
||||
400 dòng của Gate S; khoá connector thêm mới đi vào đây.
|
||||
|
||||
Ba nhóm CAD / CAE / MS365 là DANH SÁCH TÊN SẢN PHẨM nên giống hệt nhau ở cả ba
|
||||
ngôn ngữ (đã khai vào ``KHOA_KHONG_CAN_DICH`` của test i18n). Chỉ nhóm "Other"
|
||||
có chữ thật để dịch — đúng chỗ người dùng báo còn nguyên tiếng Anh.
|
||||
|
||||
``ui/connectors_panel.py`` tách nhãn tại chuỗi ``" ("`` để in phần trong ngoặc
|
||||
bằng kiểu chữ phụ, nên bản dịch phải dùng ngoặc ĐƠN NỬA CHIỀU RỘNG kèm một dấu
|
||||
cách phía trước — dùng ngoặc full-width ``(`` của tiếng Nhật thì không tách
|
||||
được và cả cụm sẽ in đậm thành một khối.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
_CAD = "CAD (NX / CATIA / SolidWorks / AutoCAD)"
|
||||
_CAE = "CAE (ANSA / ABAQUS / HyperWorks / ANSYS)"
|
||||
_MS365 = "MS365 (Microsoft 365 / OneDrive / SharePoint)"
|
||||
|
||||
STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"connectors.cat_cad": {"en": _CAD, "ja": _CAD, "vi": _CAD},
|
||||
"connectors.cat_cae": {"en": _CAE, "ja": _CAE, "vi": _CAE},
|
||||
"connectors.cat_ms365": {"en": _MS365, "ja": _MS365, "vi": _MS365},
|
||||
"connectors.cat_other": {
|
||||
"en": "Other (any generic MCP server)",
|
||||
"ja": "その他 (任意の汎用 MCP サーバー)",
|
||||
"vi": "Khác (MCP server bất kỳ)"},
|
||||
}
|
||||
@@ -344,12 +344,6 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"en": "Let the control agent review a command with AI before it runs.",
|
||||
"ja": "実行前に制御エージェントがAIでコマンドを確認します。",
|
||||
"vi": "Cho control-agent dùng AI xét lệnh trước khi chạy."},
|
||||
"settings.sandbox_pw_unset_title": {
|
||||
"en": "Sandbox Security", "ja": "サンドボックスセキュリティ", "vi": "Bảo mật Sandbox"},
|
||||
"settings.sandbox_pw_unset_body": {
|
||||
"en": "No sandbox password is set yet, so these settings stay locked. Set COWORK_SANDBOX_PASSWORD, or ask your administrator.",
|
||||
"ja": "サンドボックスのパスワードが未設定のため、この設定はロックされたままです。COWORK_SANDBOX_PASSWORD を設定するか、管理者にお問い合わせください。",
|
||||
"vi": "Chưa đặt mật khẩu sandbox nên nhóm thiết lập này vẫn khóa. Hãy đặt COWORK_SANDBOX_PASSWORD, hoặc liên hệ quản trị viên."},
|
||||
"settings.sandbox_confirm_commands": {
|
||||
"en": "Confirm before Cowork runs a command",
|
||||
"ja": "Cowork がコマンドを実行する前に確認する",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Nhãn cho các nút CHUẨN của Qt (Save/Cancel/OK/Close, Yes/No).
|
||||
|
||||
Qt tự vẽ chữ cho những nút này từ bảng dịch của chính nó, mà ứng dụng không
|
||||
cài ``QTranslator`` nào — nên chúng đứng nguyên tiếng Anh ở cả ba ngôn ngữ.
|
||||
``ui/dialog_buttons.py`` gán lại nhãn bằng các khoá dưới đây.
|
||||
|
||||
Khoá dùng chung cho mọi hộp thoại nên đứng riêng một file, không nhét vào file
|
||||
của một màn hình cụ thể.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"dialog.save": {"en": "Save", "ja": "保存", "vi": "Lưu"},
|
||||
"dialog.cancel": {"en": "Cancel", "ja": "キャンセル", "vi": "Hủy"},
|
||||
# "OK" giữ nguyên dạng ở cả ba ngôn ngữ — kể cả bản tiếng Nhật của Qt cũng
|
||||
# dùng "OK". Đã khai vào KHOA_KHONG_CAN_DICH của test i18n.
|
||||
"dialog.ok": {"en": "OK", "ja": "OK", "vi": "OK"},
|
||||
"dialog.close": {"en": "Close", "ja": "閉じる", "vi": "Đóng"},
|
||||
"dialog.yes": {"en": "Yes", "ja": "はい", "vi": "Có"},
|
||||
"dialog.no": {"en": "No", "ja": "いいえ", "vi": "Không"},
|
||||
}
|
||||
@@ -339,4 +339,23 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"en": "No usage recorded in this period yet — run a chat or a task first.",
|
||||
"ja": "この期間の使用記録はまだありません。チャットやタスクを実行してください。",
|
||||
"vi": "Chưa có dữ liệu sử dụng trong giai đoạn này — hãy chạy chat hoặc task trước."},
|
||||
# Lỗi hợp lệ hoá phụ thuộc/chuỗi task: ``core/tasks.py`` trả về KHOÁ, nơi
|
||||
# hiển thị mới gọi ``tr()`` (tầng core không biết ngôn ngữ đang chọn).
|
||||
"schedtask.err_self_wait": {
|
||||
"en": "A task cannot wait for itself.", "ja": "タスクは自分自身を待てません。",
|
||||
"vi": "Một task không thể chờ chính nó."},
|
||||
"schedtask.err_wait_cycle": {
|
||||
"en": "This would create a circular wait between tasks.",
|
||||
"ja": "タスク間で待ち合わせが循環してしまいます。",
|
||||
"vi": "Việc này sẽ tạo vòng chờ luẩn quẩn giữa các task."},
|
||||
"schedtask.err_self_chain": {
|
||||
"en": "A task cannot chain to itself.", "ja": "タスクは自分自身に連結できません。",
|
||||
"vi": "Một task không thể nối tiếp chính nó."},
|
||||
"schedtask.err_next_missing": {
|
||||
"en": "Next task does not exist.", "ja": "次のタスクが存在しません。",
|
||||
"vi": "Task kế tiếp không tồn tại."},
|
||||
"schedtask.err_chain_cycle": {
|
||||
"en": "This would create a circular task chain.",
|
||||
"ja": "タスクの連結が循環してしまいます。",
|
||||
"vi": "Việc này sẽ tạo chuỗi task luẩn quẩn."},
|
||||
}
|
||||
|
||||
@@ -134,6 +134,45 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"vi": "Bật/tắt các tool tích hợp bên dưới. Tool bị tắt sẽ bị loại khỏi bộ công cụ của agent. "
|
||||
"Connector MCP / REST-API được thiết lập ở tab con Connector."},
|
||||
"tools_admin.refresh": {"en": "Refresh", "ja": "更新", "vi": "Làm mới"},
|
||||
# Mô tả tool HIỂN THỊ trên thẻ, một khoá cho mỗi ``TOOL_SPECS[].name``.
|
||||
# KHÔNG dùng ``spec.description``: chuỗi đó là mô tả gửi cho mô hình trong
|
||||
# schema function-calling, phải giữ nguyên tiếng Anh và viết cho máy đọc.
|
||||
"tools_admin.desc.read_file": {
|
||||
"en": "Read the contents of a text file in the working folder.",
|
||||
"ja": "作業フォルダー内のテキストファイルの内容を読み取ります。",
|
||||
"vi": "Đọc nội dung một tệp văn bản trong thư mục làm việc."},
|
||||
"tools_admin.desc.list_dir": {
|
||||
"en": "List files and subfolders at a path (defaults to the workdir root).",
|
||||
"ja": "指定パスのファイルとサブフォルダーを一覧表示します(既定は作業フォルダー直下)。",
|
||||
"vi": "Liệt kê tệp và thư mục con tại một đường dẫn (mặc định là gốc thư mục làm việc)."},
|
||||
"tools_admin.desc.write_file": {
|
||||
"en": "Create a new file or fully rewrite one. For small edits, prefer edit_file.",
|
||||
"ja": "ファイルを新規作成、または全体を書き換えます。小さな修正には edit_file を使います。",
|
||||
"vi": "Tạo tệp mới hoặc ghi đè toàn bộ. Sửa nhỏ thì nên dùng edit_file."},
|
||||
"tools_admin.desc.edit_file": {
|
||||
"en": "Replace an exact snippet inside an existing file — preferred for small edits.",
|
||||
"ja": "既存ファイル内の特定の箇所を置き換えます。小さな修正に適しています。",
|
||||
"vi": "Thay chính xác một đoạn trong tệp có sẵn — hợp cho các sửa đổi nhỏ."},
|
||||
"tools_admin.desc.run_command": {
|
||||
"en": "Run a shell command in the working folder and return its output.",
|
||||
"ja": "作業フォルダーでシェルコマンドを実行し、その出力を返します。",
|
||||
"vi": "Chạy một lệnh shell trong thư mục làm việc và trả về kết quả."},
|
||||
"tools_admin.desc.install_package": {
|
||||
"en": "Install a Python package (pip) so the task can use a missing library.",
|
||||
"ja": "不足しているライブラリを使えるよう Python パッケージ(pip)をインストールします。",
|
||||
"vi": "Cài gói Python (pip) để tác vụ dùng được thư viện còn thiếu."},
|
||||
"tools_admin.desc.fetch_url": {
|
||||
"en": "Fetch a web page or online document by URL and return its text.",
|
||||
"ja": "URL から Web ページやオンライン文書を取得し、テキストを返します。",
|
||||
"vi": "Tải trang web hoặc tài liệu trực tuyến theo URL và trả về nội dung văn bản."},
|
||||
"tools_admin.desc.jira_search": {
|
||||
"en": "Search Jira issues with a JQL query and return a summary list. Read-only.",
|
||||
"ja": "JQL クエリで Jira の課題を検索し、一覧を返します。読み取り専用です。",
|
||||
"vi": "Tìm issue Jira bằng truy vấn JQL và trả về danh sách tóm tắt. Chỉ đọc."},
|
||||
"tools_admin.desc.jira_get_issue": {
|
||||
"en": "Read one Jira issue's details by key, e.g. ABX-123.",
|
||||
"ja": "キー(例: ABX-123)を指定して Jira 課題の詳細を読み取ります。",
|
||||
"vi": "Đọc chi tiết một issue Jira theo mã, ví dụ ABX-123."},
|
||||
"tools_admin.url_fetch_group": {
|
||||
"en": "Web access (fetch_url)", "ja": "Webアクセス (fetch_url)",
|
||||
"vi": "Truy cập web (fetch_url)"},
|
||||
@@ -268,6 +307,12 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"co4e.custom": {"en": "custom", "ja": "カスタム", "vi": "tùy chỉnh"},
|
||||
"co4e.parallel_node": {"en": "Parallel (fan-out)", "ja": "並列(ファンアウト)", "vi": "Song song (fan-out)"},
|
||||
"co4e.add_step": {"en": "Add step", "ja": "ステップ追加", "vi": "Thêm bước"},
|
||||
"co4e.canvas_add_next": {
|
||||
"en": "Add next step", "ja": "次のステップを追加", "vi": "Thêm bước kế"},
|
||||
"co4e.canvas_connect_from": {
|
||||
"en": "Connect from here", "ja": "ここから接続", "vi": "Nối từ đây"},
|
||||
"co4e.canvas_delete_edge": {
|
||||
"en": "Delete connection", "ja": "接続を削除", "vi": "Xóa liên kết"},
|
||||
"co4e.fit": {"en": "Fit", "ja": "全体表示", "vi": "Vừa màn hình"},
|
||||
"co4e.fit_tooltip": {
|
||||
"en": "Auto-fit: zoom to show every step", "ja": "自動フィット:全ステップを表示",
|
||||
|
||||
@@ -177,6 +177,9 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"vi": "Project cho đoạn chat mới"},
|
||||
"app.nav.no_project": {
|
||||
"en": "No project yet", "ja": "プロジェクトなし", "vi": "Chưa có project"},
|
||||
# KHAC no_project: đã có project, chỉ là người dùng chưa chọn cái nào.
|
||||
"app.nav.pick_project": {
|
||||
"en": "Select a project…", "ja": "プロジェクトを選択…", "vi": "Chọn project…"},
|
||||
"app.nav.recents": {"en": "RECENTS", "ja": "最近", "vi": "GẦN ĐÂY"},
|
||||
"app.nav.all_projects": {
|
||||
"en": "All projects…", "ja": "すべてのプロジェクト…", "vi": "Tất cả project…"},
|
||||
|
||||
+17
-11
@@ -19,13 +19,13 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"vi": "Cho phép agent lấy dữ liệu từ URL (trang web, link SharePoint / OneDrive)"},
|
||||
"settings.allow_url_fetch_tooltip": {
|
||||
"en": ("Lets the agent's fetch_url tool read web pages, online documents and "
|
||||
"SharePoint/OneDrive share links to search & process them. Separate from "
|
||||
"'Block network' (which only sandboxes shell commands). Default: on."),
|
||||
"SharePoint/OneDrive share links to search & process them. 'Block network' "
|
||||
"overrides this switch. Default: on."),
|
||||
"ja": "エージェントのfetch_urlツールがWebページ・オンライン文書・SharePoint/OneDrive共有リンクを"
|
||||
"読み取れるようにします。「ネットワークをブロック」(シェルコマンド用)とは別です。既定: オン。",
|
||||
"読み取れるようにします。「ネットワークをブロック」がオンの場合はそちらが優先されます。既定: オン。",
|
||||
"vi": ("Cho phép tool fetch_url của agent đọc trang web, tài liệu online và link chia sẻ "
|
||||
"SharePoint/OneDrive để tìm kiếm & xử lý. Tách biệt với 'Chặn mạng' (chỉ áp cho lệnh "
|
||||
"shell). Mặc định: bật.")},
|
||||
"SharePoint/OneDrive để tìm kiếm & xử lý. 'Chặn mạng' được ưu tiên hơn công tắc "
|
||||
"này. Mặc định: bật.")},
|
||||
"settings.test_internet": {
|
||||
"en": "Test Internet", "ja": "インターネット接続テスト", "vi": "Kiểm tra Internet"},
|
||||
"settings.test_internet_tooltip": {
|
||||
@@ -39,12 +39,18 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"en": "Testing internet access…", "ja": "インターネット接続をテスト中…",
|
||||
"vi": "Đang kiểm tra truy cập internet…"},
|
||||
"settings.sandbox_block_network_tooltip": {
|
||||
"en": ("Policy-level control (proxy env vars point at a black hole) — not a kernel "
|
||||
"firewall. Combine with the command whitelist above for defense in depth."),
|
||||
"ja": "ポリシーレベルの制御です(プロキシ環境変数をブラックホールに向ける)— カーネルレベルの"
|
||||
"ファイアウォールではありません。上のコマンドホワイトリストと併用してください。",
|
||||
"vi": "Kiểm soát ở tầng chính sách (trỏ biến môi trường proxy vào hố đen) — không phải "
|
||||
"firewall tầng kernel. Kết hợp với whitelist lệnh ở trên để phòng thủ nhiều lớp."},
|
||||
"en": ("Only the AI provider (chat, model list, model test) may reach the network. "
|
||||
"Agent shell commands and task scripts run in a Windows AppContainer with no "
|
||||
"network access; fetch_url, Jira, connectors/MCP, Microsoft 365, Teams, "
|
||||
"connector tests, pip auto-install and remote content in HTML previews are refused."),
|
||||
"ja": "ネットワークに接続できるのはAIプロバイダー(チャット・モデル一覧・モデルテスト)のみです。"
|
||||
"エージェントのシェルコマンドとタスクスクリプトはネットワークなしのWindows AppContainerで実行され、"
|
||||
"fetch_url・Jira・コネクタ/MCP・Microsoft 365・Teams・接続テスト・pip自動インストール・"
|
||||
"HTMLプレビューの外部リソースは拒否されます。",
|
||||
"vi": "Chỉ nhà cung cấp AI (chat, tải danh sách model, thử model) được ra mạng. Lệnh shell "
|
||||
"của agent và task script chạy trong Windows AppContainer không có mạng; fetch_url, "
|
||||
"Jira, connector/MCP, Microsoft 365, Teams, nút Test, tự cài thư viện và tài nguyên "
|
||||
"web trong xem trước HTML đều bị từ chối."},
|
||||
"settings.sandbox_unlimited": {"en": "Unlimited", "ja": "無制限", "vi": "Không giới hạn"},
|
||||
"settings.sandbox_cpu_label": {"en": "CPU limit", "ja": "CPU 制限", "vi": "Giới hạn CPU"},
|
||||
"settings.sandbox_memory_label": {"en": "Memory limit", "ja": "メモリ制限", "vi": "Giới hạn bộ nhớ"},
|
||||
|
||||
+29
-1
@@ -57,6 +57,17 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"en": "Another project is already called \"{name}\". Project names must be unique — the list shows nothing but the name, so two of them cannot be told apart.",
|
||||
"ja": "「{name}」という名前のプロジェクトが既にあります。一覧には名前しか出ないため、同じ名前が二つあると区別できません。",
|
||||
"vi": "Đã có project khác tên \"{name}\". Tên project phải khác nhau — danh sách chỉ hiện tên, trùng tên là không phân biệt được."},
|
||||
"workspace.folder_taken_title": {
|
||||
"en": "Folder already used", "ja": "フォルダーが重複しています",
|
||||
"vi": "Thư mục đã được dùng"},
|
||||
"workspace.folder_taken_body": {
|
||||
"en": "Project \"{name}\" already works in {folder}. One folder belongs to one project only — the folder is that project's sandbox and shared knowledge, so sharing it lets two projects read and overwrite each other's files. Pick another folder.",
|
||||
"ja": "プロジェクト「{name}」が既に {folder} を使用しています。フォルダーは 1 つのプロジェクト専用です — フォルダーはそのプロジェクトのサンドボックス兼共有ナレッジなので、共有すると互いのファイルを読み書きしてしまいます。別のフォルダーを選んでください。",
|
||||
"vi": "Project \"{name}\" đang làm việc trong {folder}. Mỗi thư mục chỉ thuộc về một project — thư mục vừa là sandbox vừa là kho kiến thức chung của project đó, dùng chung là hai project đọc và ghi đè file của nhau. Hãy chọn thư mục khác."},
|
||||
"workspace.folder_shared_warning": {
|
||||
"en": "⚠ This folder is also used by project \"{name}\". One folder belongs to one project only — pick another folder for one of them.",
|
||||
"ja": "⚠ このフォルダーはプロジェクト「{name}」でも使われています。フォルダーは 1 つのプロジェクト専用です — どちらかに別のフォルダーを指定してください。",
|
||||
"vi": "⚠ Thư mục này đang được project \"{name}\" dùng chung. Mỗi thư mục chỉ thuộc về một project — hãy đổi thư mục cho một trong hai."},
|
||||
"workspace.instructions_placeholder": {
|
||||
"en": "e.g. \"All answers in Vietnamese. We are building the X reporting tool; always follow the naming rules …\"",
|
||||
"ja": "例:「回答はすべて日本語で。X レポートツールを開発中。命名規則に従うこと …」",
|
||||
@@ -79,7 +90,10 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"app.status.ready": {"en": "Ready.", "ja": "準備完了。", "vi": "Sẵn sàng."},
|
||||
"app.status.using_provider": {"en": "Using {label}.", "ja": "{label} を使用中。", "vi": "Đang dùng {label}."},
|
||||
"app.status.settings_saved": {"en": "Settings saved.", "ja": "設定を保存しました。", "vi": "Đã lưu cài đặt."},
|
||||
"app.credit": {"en": "Made by QuanDH14", "ja": "Made by QuanDH14", "vi": "Made by QuanDH14"},
|
||||
# Con số lấy từ ``cowork_local.__version__`` — một nguồn duy nhất cho tiêu
|
||||
# đề cửa sổ, tab Giới thiệu và góc dưới phải. Giữ nguyên dạng ở cả ba ngôn
|
||||
# ngữ (đã khai vào KHOA_KHONG_CAN_DICH).
|
||||
"app.version": {"en": "Version {v}", "ja": "Version {v}", "vi": "Version {v}"},
|
||||
"app.tray.open": {"en": "Open Cowork Local", "ja": "Cowork Local を開く", "vi": "Mở Cowork Local"},
|
||||
"app.tray.quit": {"en": "Quit", "ja": "終了", "vi": "Thoát"},
|
||||
"app.tray.running_body": {
|
||||
@@ -197,6 +211,15 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"chat.provider_default_short": {
|
||||
"en": "the provider's default model", "ja": "プロバイダー既定のモデル",
|
||||
"vi": "model mặc định của provider"},
|
||||
"chat.provider_default_item": {
|
||||
"en": "(provider default)", "ja": "(プロバイダー既定)",
|
||||
"vi": "(mặc định của provider)"},
|
||||
"chat.record_audio_start": {
|
||||
"en": "Record Voice Note", "ja": "ボイスメモを録音", "vi": "Ghi âm ghi chú"},
|
||||
"chat.record_audio_stop": {
|
||||
"en": "Stop Recording", "ja": "録音を停止", "vi": "Dừng ghi âm"},
|
||||
"chat.record_audio_cancel": {
|
||||
"en": "Cancel recording", "ja": "録音をキャンセル", "vi": "Hủy ghi âm"},
|
||||
"chat.delete_link": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
|
||||
"chat.delete_tooltip": {
|
||||
"en": "Delete this message and its input/output files",
|
||||
@@ -206,6 +229,11 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"chat.open_folder": {"en": "Open folder", "ja": "フォルダを開く", "vi": "Mở thư mục"},
|
||||
"chat.open_output_folder": {"en": "Open output folder", "ja": "出力フォルダを開く", "vi": "Mở thư mục output"},
|
||||
"chat.done_marker": {"en": "Done", "ja": "完了しました", "vi": "Đã hoàn thành"},
|
||||
"chat.stopping": {"en": "Stopping", "ja": "停止中", "vi": "Đang dừng"},
|
||||
"chat.stopped_marker": {
|
||||
"en": "⏹ Stopped at your request",
|
||||
"ja": "⏹ リクエストにより停止しました",
|
||||
"vi": "⏹ Đã dừng theo yêu cầu"},
|
||||
"chat.session_folder_marker": {
|
||||
"en": "This conversation's output folder", "ja": "この会話の出力フォルダ",
|
||||
"vi": "Thư mục output của hội thoại này"},
|
||||
|
||||
@@ -62,7 +62,9 @@ def run_command(ctx: ToolContext, args: Dict[str, Any],
|
||||
"""
|
||||
from cowork_local.core.deps import network_blocked_env, run_cancellable, sandbox_env
|
||||
from cowork_local.core.sandbox_manager import ExecutionConfig, SandboxManager
|
||||
from cowork_local.security.command_risk_classifier import classify_command
|
||||
from cowork_local.security.command_risk_classifier import (
|
||||
classify_command, command_bypasses_network_proxy,
|
||||
)
|
||||
|
||||
command = str(args.get("command", "")).strip()
|
||||
if not command:
|
||||
@@ -74,6 +76,21 @@ def run_command(ctx: ToolContext, args: Dict[str, Any],
|
||||
denial = "Command blocked by security policy: " + "; ".join(risk.reasons)
|
||||
return {"ok": False, "output": denial}
|
||||
|
||||
# With the network blocked, SandboxManager runs the command in an OS-level
|
||||
# network-less process (AppContainer on Windows — see
|
||||
# infrastructure/sandbox/network_isolation.py). Tools that exist only to
|
||||
# reach the network (ping, nslookup, ssh...) are still denied BY NAME
|
||||
# first: the model gets a clear reason instead of a cryptic socket error.
|
||||
if ctx.block_network:
|
||||
bypass_tool = command_bypasses_network_proxy(command)
|
||||
if bypass_tool:
|
||||
return {"ok": False, "output": (
|
||||
f"Command blocked: '{bypass_tool}' can reach the network without going through "
|
||||
"an HTTP proxy, so the sandbox's network block (which only filters proxy-aware "
|
||||
"traffic) cannot stop it by itself — blocked by name instead while "
|
||||
"'Chặn mạng cho lệnh do agent chạy' is on."
|
||||
)}
|
||||
|
||||
# Route through SandboxManager for risk-based isolation
|
||||
mgr = SandboxManager(ExecutionConfig(
|
||||
enabled=True,
|
||||
@@ -111,6 +128,15 @@ def install_package(ctx: ToolContext, args: Dict[str, Any],
|
||||
package = str(args.get("package", "")).strip()
|
||||
if not package:
|
||||
return {"ok": False, "output": "No package specified."}
|
||||
# ``pip install`` bắt buộc phải ra internet, mà ``deps.pip_install`` chạy
|
||||
# subprocess với ``os.environ`` nguyên vẹn — biến proxy hố đen của
|
||||
# ``network_blocked_env`` không chạm tới nó. Từ chối thẳng ở đây (giống cách
|
||||
# run_command chặn theo tên các công cụ không đi qua proxy) thay vì để pip
|
||||
# thử 600 giây rồi báo một lỗi proxy khó hiểu.
|
||||
if ctx.block_network:
|
||||
return {"ok": False, "output": (
|
||||
"install_package: network access is blocked by the Sandbox Security Layer "
|
||||
"(\"Block network for agent-run commands\" is on in Settings).")}
|
||||
python = _sandbox_python(ctx, cancel, on_output)
|
||||
ok, detail = pip_install(package, cancel=cancel, on_output=on_output, python=python)
|
||||
head = f"Installed {package}." if ok else f"Could not install {package}."
|
||||
|
||||
@@ -6,11 +6,26 @@ tag added in R05-T01/domain/tools/tool_registry.py describes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .tool_context import ToolContext
|
||||
|
||||
|
||||
def _network_refusal(ctx: ToolContext, tool: str) -> Optional[Dict[str, Any]]:
|
||||
"""Lời từ chối khi Sandbox Security Layer đang chặn mạng; None nếu được đi.
|
||||
|
||||
``block_network`` trước đây chỉ được đọc ở ``command_tools.py`` (lệnh shell),
|
||||
nên ba tool mang ``ToolCapability.NETWORK`` ở file này vẫn ra internet bình
|
||||
thường trong khi Monitoring báo "Mạng: Bị chặn". Kiểm ở đây, TRƯỚC mọi lời
|
||||
gọi mạng, để công tắc chặn đúng thứ nó nói là chặn.
|
||||
"""
|
||||
if not ctx.block_network:
|
||||
return None
|
||||
return {"ok": False, "output": (
|
||||
f"{tool}: network access is blocked by the Sandbox Security Layer "
|
||||
"(\"Block network for agent-run commands\" is on in Settings).")}
|
||||
|
||||
|
||||
def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Fetch a URL's text content (web page / online document / SharePoint-
|
||||
OneDrive share link) via link_fetch — the same parser task-link attachments
|
||||
@@ -20,6 +35,9 @@ def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {"ok": False, "output": "fetch_url: 'url' is required."}
|
||||
if not url.lower().startswith(("http://", "https://")):
|
||||
return {"ok": False, "output": f"fetch_url: not an http(s) URL: {url}"}
|
||||
blocked = _network_refusal(ctx, "fetch_url")
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
if not ctx.allow_url_fetch:
|
||||
return {"ok": False,
|
||||
"output": ("fetch_url: URL fetching is turned off in Settings → Security "
|
||||
@@ -37,6 +55,9 @@ def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
def jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Tìm issue trên Jira bằng JQL."""
|
||||
blocked = _network_refusal(ctx, "jira_search")
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
from cowork_local.core import jira_tool
|
||||
|
||||
out = jira_tool.search(ctx.jira, str(args.get("jql", "")),
|
||||
@@ -47,6 +68,9 @@ def jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
def jira_get_issue(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Lấy chi tiết một issue Jira theo mã."""
|
||||
blocked = _network_refusal(ctx, "jira_get_issue")
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
from cowork_local.core import jira_tool
|
||||
|
||||
out = jira_tool.get_issue(ctx.jira, str(args.get("key", "")))
|
||||
|
||||
@@ -37,12 +37,16 @@ class ToolContext:
|
||||
# None (default) = no limits, matching pre-existing behavior.
|
||||
resource_limits: Optional[Dict[str, float]] = None
|
||||
# Sandbox Security Layer — Settings' "Block network for agent commands"
|
||||
# (policy-level, see deps.py::network_blocked_env). False (default) =
|
||||
# — the proxy-env block for shell commands (deps.py::network_blocked_env)
|
||||
# AND a flat refusal from every NETWORK-capability tool, which reaches the
|
||||
# net in-process where proxy env vars mean nothing. False (default) =
|
||||
# unrestricted, matching pre-existing behavior.
|
||||
block_network: bool = False
|
||||
# Whether the fetch_url tool may read URLs — SEPARATE from block_network
|
||||
# (reading a web page/share link for info is safe; running networked shell
|
||||
# commands is the risk). Defaults True; set from agent_security.allow_url_fetch.
|
||||
# Whether the fetch_url tool may read URLs. Its own toggle, but NOT a way
|
||||
# around block_network: with the network blocked every NETWORK-capability
|
||||
# tool is refused first (fetch_tools.py::_network_refusal), so this flag only
|
||||
# decides anything while the network is open. Defaults True; set from
|
||||
# agent_security.allow_url_fetch.
|
||||
allow_url_fetch: bool = True
|
||||
# Jira read connector config (base_url/email/api_token) — None disables the
|
||||
# jira_* tools' ability to connect. Populated from config.data["jira"].
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
"""Spawn a shell command inside a Windows AppContainer with NO network capability.
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
The old "block network" for agent shell commands only pointed the proxy env
|
||||
vars at a dead port (``core/deps.py::network_blocked_env``). Anything that
|
||||
ignores proxies (``Invoke-WebRequest -NoProxy``, raw sockets, ``certutil``,
|
||||
.NET ``WebClient``...) still reached the internet. An AppContainer token that
|
||||
is granted no ``internetClient``/``privateNetworkClientServer`` capability is
|
||||
refused by the kernel firewall for every outbound connection, loopback
|
||||
included, no matter which tool makes it. No admin rights are needed.
|
||||
|
||||
An AppContainer can only open files whose ACL admits its SID (or ALL
|
||||
APPLICATION PACKAGES). System32 and Program Files already do; the workdir and
|
||||
the app's own Python install do not, so :func:`spawn` grants the profile SID
|
||||
access to those folders first (an extra ACE, nothing is removed).
|
||||
|
||||
:class:`AppContainerProcess` quacks like ``subprocess.Popen`` for the subset
|
||||
``core/deps.py::_run_cancellable_body`` uses (``pid``, ``stdout``/``stderr``
|
||||
text streams, ``poll``/``wait``/``kill``/``returncode``), so the Stop button,
|
||||
timeouts, Job Objects and resource limits keep working unchanged.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import locale
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from typing import Dict, Iterable, Optional
|
||||
|
||||
PROFILE_NAME = "cowork_local.agent_netblock"
|
||||
_IS_WINDOWS = sys.platform == "win32"
|
||||
|
||||
if _IS_WINDOWS:
|
||||
import ctypes
|
||||
import msvcrt
|
||||
from ctypes import wintypes
|
||||
|
||||
_k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
_adv = ctypes.WinDLL("advapi32", use_last_error=True)
|
||||
_uenv = ctypes.WinDLL("userenv", use_last_error=True)
|
||||
|
||||
class _SECURITY_CAPABILITIES(ctypes.Structure):
|
||||
_fields_ = [("AppContainerSid", ctypes.c_void_p), ("Capabilities", ctypes.c_void_p),
|
||||
("CapabilityCount", wintypes.DWORD), ("Reserved", wintypes.DWORD)]
|
||||
|
||||
class _STARTUPINFOW(ctypes.Structure):
|
||||
_fields_ = [("cb", wintypes.DWORD), ("lpReserved", wintypes.LPWSTR),
|
||||
("lpDesktop", wintypes.LPWSTR), ("lpTitle", wintypes.LPWSTR),
|
||||
("dwX", wintypes.DWORD), ("dwY", wintypes.DWORD),
|
||||
("dwXSize", wintypes.DWORD), ("dwYSize", wintypes.DWORD),
|
||||
("dwXCountChars", wintypes.DWORD), ("dwYCountChars", wintypes.DWORD),
|
||||
("dwFillAttribute", wintypes.DWORD), ("dwFlags", wintypes.DWORD),
|
||||
("wShowWindow", wintypes.WORD), ("cbReserved2", wintypes.WORD),
|
||||
("lpReserved2", ctypes.c_void_p), ("hStdInput", wintypes.HANDLE),
|
||||
("hStdOutput", wintypes.HANDLE), ("hStdError", wintypes.HANDLE)]
|
||||
|
||||
class _STARTUPINFOEXW(ctypes.Structure):
|
||||
_fields_ = [("StartupInfo", _STARTUPINFOW), ("lpAttributeList", ctypes.c_void_p)]
|
||||
|
||||
class _PROCESS_INFORMATION(ctypes.Structure):
|
||||
_fields_ = [("hProcess", wintypes.HANDLE), ("hThread", wintypes.HANDLE),
|
||||
("dwProcessId", wintypes.DWORD), ("dwThreadId", wintypes.DWORD)]
|
||||
|
||||
class _SECURITY_ATTRIBUTES(ctypes.Structure):
|
||||
_fields_ = [("nLength", wintypes.DWORD), ("lpSecurityDescriptor", ctypes.c_void_p),
|
||||
("bInheritHandle", wintypes.BOOL)]
|
||||
|
||||
_uenv.CreateAppContainerProfile.restype = ctypes.c_long
|
||||
_uenv.CreateAppContainerProfile.argtypes = [
|
||||
wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.LPCWSTR, ctypes.c_void_p,
|
||||
wintypes.DWORD, ctypes.POINTER(ctypes.c_void_p)]
|
||||
_uenv.DeriveAppContainerSidFromAppContainerName.restype = ctypes.c_long
|
||||
_uenv.DeriveAppContainerSidFromAppContainerName.argtypes = [
|
||||
wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_void_p)]
|
||||
_uenv.GetAppContainerFolderPath.restype = ctypes.c_long
|
||||
_uenv.GetAppContainerFolderPath.argtypes = [
|
||||
wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_wchar_p)]
|
||||
_adv.ConvertSidToStringSidW.restype = wintypes.BOOL
|
||||
_adv.ConvertSidToStringSidW.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_wchar_p)]
|
||||
_k32.InitializeProcThreadAttributeList.restype = wintypes.BOOL
|
||||
_k32.InitializeProcThreadAttributeList.argtypes = [
|
||||
ctypes.c_void_p, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(ctypes.c_size_t)]
|
||||
_k32.UpdateProcThreadAttribute.restype = wintypes.BOOL
|
||||
_k32.UpdateProcThreadAttribute.argtypes = [
|
||||
ctypes.c_void_p, wintypes.DWORD, ctypes.c_size_t, ctypes.c_void_p,
|
||||
ctypes.c_size_t, ctypes.c_void_p, ctypes.c_void_p]
|
||||
_k32.DeleteProcThreadAttributeList.argtypes = [ctypes.c_void_p]
|
||||
_k32.CreatePipe.restype = wintypes.BOOL
|
||||
_k32.CreatePipe.argtypes = [ctypes.POINTER(wintypes.HANDLE), ctypes.POINTER(wintypes.HANDLE),
|
||||
ctypes.POINTER(_SECURITY_ATTRIBUTES), wintypes.DWORD]
|
||||
_k32.SetHandleInformation.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD]
|
||||
_k32.CreateProcessW.restype = wintypes.BOOL
|
||||
_k32.CreateProcessW.argtypes = [
|
||||
wintypes.LPCWSTR, wintypes.LPWSTR, ctypes.c_void_p, ctypes.c_void_p, wintypes.BOOL,
|
||||
wintypes.DWORD, ctypes.c_void_p, wintypes.LPCWSTR, ctypes.POINTER(_STARTUPINFOEXW),
|
||||
ctypes.POINTER(_PROCESS_INFORMATION)]
|
||||
_k32.ResumeThread.argtypes = [wintypes.HANDLE]
|
||||
_k32.WaitForSingleObject.restype = wintypes.DWORD
|
||||
_k32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
|
||||
_k32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
|
||||
_k32.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT]
|
||||
_k32.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
_k32.GetStdHandle.restype = wintypes.HANDLE
|
||||
_k32.OpenProcess.restype = wintypes.HANDLE
|
||||
|
||||
_ALREADY_EXISTS = ctypes.c_long(0x800700B7).value
|
||||
_PROC_THREAD_ATTRIBUTE_HANDLE_LIST = 0x00020002
|
||||
_PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES = 0x00020009
|
||||
_EXTENDED_STARTUPINFO_PRESENT = 0x00080000
|
||||
_CREATE_UNICODE_ENVIRONMENT = 0x00000400
|
||||
_CREATE_NO_WINDOW = 0x08000000
|
||||
_CREATE_SUSPENDED = 0x00000004
|
||||
_STARTF_USESTDHANDLES = 0x00000100
|
||||
_HANDLE_FLAG_INHERIT = 0x00000001
|
||||
_STILL_ACTIVE = 259
|
||||
|
||||
_profile_lock = threading.Lock()
|
||||
_profile: Dict[str, object] = {}
|
||||
_granted: set = set()
|
||||
|
||||
|
||||
class NetworkIsolationUnavailable(RuntimeError):
|
||||
"""This machine cannot start a network-less process — callers must refuse to run."""
|
||||
|
||||
|
||||
def is_supported() -> bool:
|
||||
"""True on Windows builds that ship the AppContainer API (Windows 8+)."""
|
||||
if not _IS_WINDOWS:
|
||||
return False
|
||||
try:
|
||||
return bool(_uenv.CreateAppContainerProfile)
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
|
||||
def _profile_sid():
|
||||
"""``(sid pointer, sid string, temp folder)`` of the shared no-network profile."""
|
||||
with _profile_lock:
|
||||
if _profile:
|
||||
return _profile["sid"], _profile["sid_str"], _profile["temp"]
|
||||
sid = ctypes.c_void_p()
|
||||
hr = _uenv.CreateAppContainerProfile(PROFILE_NAME, "Cowork Local agent (no network)",
|
||||
"Agent shell commands with the network blocked",
|
||||
None, 0, ctypes.byref(sid))
|
||||
if hr == _ALREADY_EXISTS:
|
||||
hr = _uenv.DeriveAppContainerSidFromAppContainerName(PROFILE_NAME, ctypes.byref(sid))
|
||||
if hr != 0 or not sid.value:
|
||||
raise NetworkIsolationUnavailable(f"AppContainer profile error 0x{hr & 0xFFFFFFFF:08X}")
|
||||
text = ctypes.c_wchar_p()
|
||||
if not _adv.ConvertSidToStringSidW(sid, ctypes.byref(text)):
|
||||
raise NetworkIsolationUnavailable("Could not read the AppContainer SID")
|
||||
sid_str = text.value
|
||||
folder = ctypes.c_wchar_p()
|
||||
temp = ""
|
||||
if _uenv.GetAppContainerFolderPath(sid_str, ctypes.byref(folder)) == 0 and folder.value:
|
||||
temp = os.path.join(folder.value, "Temp")
|
||||
os.makedirs(temp, exist_ok=True)
|
||||
_profile.update(sid=sid, sid_str=sid_str, temp=temp)
|
||||
return sid, sid_str, temp
|
||||
|
||||
|
||||
_PERMS = {"write": "(OI)(CI)(M)", "read": "(OI)(CI)(RX)", "read_here": "(OI)(NP)(RX)"}
|
||||
|
||||
|
||||
def _qt_package_dir() -> str:
|
||||
"""Folder of the installed PySide6/Qt binaries ('' if PySide6 is absent)."""
|
||||
try:
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.find_spec("PySide6")
|
||||
except (ImportError, ValueError):
|
||||
return ""
|
||||
return os.path.dirname(spec.origin) if spec and spec.origin else ""
|
||||
|
||||
|
||||
def _covers(folder: str, target: str) -> bool:
|
||||
"""True if ``target`` is ``folder`` itself or lies somewhere below it."""
|
||||
if not folder or not target:
|
||||
return False
|
||||
folder, target = os.path.normcase(folder), os.path.normcase(target)
|
||||
return target == folder or target.startswith(folder.rstrip("\\/") + os.sep)
|
||||
|
||||
|
||||
def _icacls(*args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(["icacls", *args], capture_output=True, text=True,
|
||||
creationflags=_CREATE_NO_WINDOW)
|
||||
|
||||
|
||||
def grant_access(path: str, sid_str: str, mode: str) -> None:
|
||||
"""Let the AppContainer SID open ``path``.
|
||||
|
||||
``mode`` is ``"write"``/``"read"`` (inherited by everything below) or
|
||||
``"read_here"`` (this folder and the files directly in it, no deeper).
|
||||
|
||||
An inherited ACE must never reach the Qt WebEngine binaries: Chromium's
|
||||
sandboxed render process then fails to load Qt6WebEngineCore.dll
|
||||
(STATUS_DLL_NOT_FOUND) and every web view in the app goes blank. A folder
|
||||
that contains the PySide6 install is therefore refused.
|
||||
"""
|
||||
path = os.path.abspath(path)
|
||||
key = (os.path.normcase(path), mode)
|
||||
if key in _granted or not os.path.exists(path):
|
||||
return
|
||||
if mode != "read_here" and _covers(path, _qt_package_dir()):
|
||||
raise NetworkIsolationUnavailable(
|
||||
f"Refusing to sandbox a folder that contains the app's Qt runtime: {path}")
|
||||
perm = _PERMS[mode]
|
||||
listing = (_icacls(path).stdout or "").lower()
|
||||
if f"{sid_str}:{perm}".lower() not in listing:
|
||||
done = _icacls(path, "/grant", f"*{sid_str}:{perm}", "/Q", "/C")
|
||||
if done.returncode != 0:
|
||||
raise NetworkIsolationUnavailable(
|
||||
f"Could not grant the sandbox access to {path}: {(done.stderr or done.stdout).strip()}")
|
||||
_granted.add(key)
|
||||
|
||||
|
||||
def _repair_inherited_grant(path: str, sid_str: str) -> None:
|
||||
"""Drop an inherited grant that an earlier build put on the app's venv."""
|
||||
key = (os.path.normcase(os.path.abspath(path)), "repaired")
|
||||
if key in _granted or not os.path.isdir(path):
|
||||
return
|
||||
listing = (_icacls(path).stdout or "").lower()
|
||||
if f"{sid_str}:(oi)(ci)".lower() in listing:
|
||||
_icacls(path, "/remove:g", f"*{sid_str}", "/Q", "/C")
|
||||
_granted.add(key)
|
||||
|
||||
|
||||
def _interpreter_grants():
|
||||
"""``(folder, mode)`` pairs that let the sandbox run the app's Python.
|
||||
|
||||
The base install is read-only and holds no Qt; a venv only needs its root
|
||||
(``pyvenv.cfg``) and ``Scripts`` — never ``Lib/site-packages``.
|
||||
"""
|
||||
qt_dir = _qt_package_dir()
|
||||
if _covers(sys.base_prefix, qt_dir):
|
||||
# PySide6 lives in the base install itself: expose only the executable
|
||||
# and the compiled stdlib modules, never the tree holding Qt.
|
||||
grants = [(sys.base_prefix, "read_here"), (os.path.join(sys.base_prefix, "DLLs"), "read")]
|
||||
else:
|
||||
grants = [(sys.base_prefix, "read")]
|
||||
if os.path.normcase(sys.prefix) != os.path.normcase(sys.base_prefix):
|
||||
grants += [(sys.prefix, "read_here"), (os.path.join(sys.prefix, "Scripts"), "read")]
|
||||
return [(folder, mode) for folder, mode in grants
|
||||
if mode == "read_here" or not _covers(folder, qt_dir)]
|
||||
|
||||
|
||||
def _env_block(env: Dict[str, str]) -> ctypes.Array:
|
||||
"""Sorted, double-NUL-terminated UTF-16 environment block for CreateProcessW."""
|
||||
items = sorted(env.items(), key=lambda kv: kv[0].upper())
|
||||
text = "".join(f"{k}={v}\0" for k, v in items if k and "=" not in k) + "\0"
|
||||
return ctypes.create_unicode_buffer(text, len(text))
|
||||
|
||||
|
||||
def _pipe():
|
||||
"""Anonymous pipe; only the child's (write) end is inheritable."""
|
||||
sa = _SECURITY_ATTRIBUTES(ctypes.sizeof(_SECURITY_ATTRIBUTES), None, True)
|
||||
read, write = wintypes.HANDLE(), wintypes.HANDLE()
|
||||
if not _k32.CreatePipe(ctypes.byref(read), ctypes.byref(write), ctypes.byref(sa), 0):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
_k32.SetHandleInformation(read, _HANDLE_FLAG_INHERIT, 0)
|
||||
return read, write
|
||||
|
||||
|
||||
class AppContainerProcess:
|
||||
"""The ``subprocess.Popen`` subset that ``deps._run_cancellable_body`` relies on."""
|
||||
|
||||
def __init__(self, handle, pid: int, stdout: io.TextIOBase, stderr: io.TextIOBase):
|
||||
"""Wrap an already-started process handle and its two output streams."""
|
||||
self._handle = handle
|
||||
self.pid = pid
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
self.returncode: Optional[int] = None
|
||||
|
||||
def poll(self) -> Optional[int]:
|
||||
"""Exit code if the process has finished, else None."""
|
||||
if self.returncode is None and self._handle:
|
||||
code = wintypes.DWORD()
|
||||
if _k32.GetExitCodeProcess(self._handle, ctypes.byref(code)) and code.value != _STILL_ACTIVE:
|
||||
self.returncode = ctypes.c_int32(code.value).value
|
||||
_k32.CloseHandle(self._handle)
|
||||
self._handle = None
|
||||
return self.returncode
|
||||
|
||||
def wait(self, timeout: Optional[float] = None) -> int:
|
||||
"""Block until exit; raise ``subprocess.TimeoutExpired`` like Popen does."""
|
||||
if self.returncode is None and self._handle:
|
||||
ms = 0xFFFFFFFF if timeout is None else int(timeout * 1000)
|
||||
if _k32.WaitForSingleObject(self._handle, ms) != 0:
|
||||
raise subprocess.TimeoutExpired("appcontainer", timeout)
|
||||
return self.poll()
|
||||
|
||||
def kill(self) -> None:
|
||||
"""Terminate the process (the Job Object in deps kills its children)."""
|
||||
if self.returncode is None and self._handle:
|
||||
_k32.TerminateProcess(self._handle, 1)
|
||||
|
||||
def communicate(self, timeout: Optional[float] = None):
|
||||
"""Read both streams to the end and wait; returns ``(stdout, stderr)``."""
|
||||
chunks: Dict[str, str] = {}
|
||||
|
||||
def _drain(name, stream):
|
||||
chunks[name] = stream.read()
|
||||
|
||||
readers = [threading.Thread(target=_drain, args=(n, s), daemon=True)
|
||||
for n, s in (("out", self.stdout), ("err", self.stderr))]
|
||||
for t in readers:
|
||||
t.start()
|
||||
for t in readers:
|
||||
t.join(timeout)
|
||||
self.wait(timeout)
|
||||
return chunks.get("out", ""), chunks.get("err", "")
|
||||
|
||||
|
||||
def spawn(command: str, cwd: Optional[str], env: Optional[Dict[str, str]],
|
||||
readable_dirs: Iterable[str] = ()) -> AppContainerProcess:
|
||||
"""Start ``cmd.exe /c command`` in the no-network AppContainer.
|
||||
|
||||
Raises :class:`NetworkIsolationUnavailable` when that cannot be done —
|
||||
callers must then refuse the command rather than run it with the network on.
|
||||
"""
|
||||
if not is_supported():
|
||||
raise NetworkIsolationUnavailable("AppContainer is only available on Windows")
|
||||
sid, sid_str, temp = _profile_sid()
|
||||
cwd = os.path.abspath(cwd or os.getcwd())
|
||||
_repair_inherited_grant(sys.prefix, sid_str)
|
||||
grant_access(cwd, sid_str, "write")
|
||||
for folder, mode in [*_interpreter_grants(), *((d, "read") for d in readable_dirs if d)]:
|
||||
grant_access(folder, sid_str, mode)
|
||||
|
||||
child_env = dict(os.environ if env is None else env)
|
||||
if temp:
|
||||
child_env["TEMP"] = child_env["TMP"] = temp
|
||||
child_env.setdefault("PYTHONIOENCODING", "utf-8")
|
||||
env_block = _env_block(child_env)
|
||||
|
||||
out_r, out_w = _pipe()
|
||||
err_r, err_w = _pipe()
|
||||
handles = (wintypes.HANDLE * 2)(out_w, err_w)
|
||||
caps = _SECURITY_CAPABILITIES(sid, None, 0, 0)
|
||||
size = ctypes.c_size_t()
|
||||
_k32.InitializeProcThreadAttributeList(None, 2, 0, ctypes.byref(size))
|
||||
attr = ctypes.create_string_buffer(size.value)
|
||||
pi = _PROCESS_INFORMATION()
|
||||
try:
|
||||
if not (_k32.InitializeProcThreadAttributeList(attr, 2, 0, ctypes.byref(size))
|
||||
and _k32.UpdateProcThreadAttribute(
|
||||
attr, 0, _PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, ctypes.byref(caps),
|
||||
ctypes.sizeof(caps), None, None)
|
||||
and _k32.UpdateProcThreadAttribute(
|
||||
attr, 0, _PROC_THREAD_ATTRIBUTE_HANDLE_LIST, handles,
|
||||
ctypes.sizeof(handles), None, None)):
|
||||
raise NetworkIsolationUnavailable(str(ctypes.WinError(ctypes.get_last_error())))
|
||||
si = _STARTUPINFOEXW()
|
||||
si.StartupInfo.cb = ctypes.sizeof(si)
|
||||
si.StartupInfo.dwFlags = _STARTF_USESTDHANDLES
|
||||
si.StartupInfo.hStdOutput = out_w
|
||||
si.StartupInfo.hStdError = err_w
|
||||
si.lpAttributeList = ctypes.addressof(attr)
|
||||
comspec = os.environ.get("COMSPEC") or r"C:\Windows\System32\cmd.exe"
|
||||
cmdline = ctypes.create_unicode_buffer(f'"{comspec}" /d /s /c "{command}"')
|
||||
flags = (_EXTENDED_STARTUPINFO_PRESENT | _CREATE_UNICODE_ENVIRONMENT
|
||||
| _CREATE_NO_WINDOW | _CREATE_SUSPENDED)
|
||||
if not _k32.CreateProcessW(None, cmdline, None, None, True, flags,
|
||||
ctypes.addressof(env_block), cwd,
|
||||
ctypes.byref(si), ctypes.byref(pi)):
|
||||
raise NetworkIsolationUnavailable(
|
||||
f"Could not start the sandboxed command: {ctypes.WinError(ctypes.get_last_error())}")
|
||||
_k32.ResumeThread(pi.hThread)
|
||||
_k32.CloseHandle(pi.hThread)
|
||||
except BaseException:
|
||||
for h in (out_r, err_r):
|
||||
_k32.CloseHandle(h)
|
||||
raise
|
||||
finally:
|
||||
_k32.DeleteProcThreadAttributeList(attr)
|
||||
_k32.CloseHandle(out_w)
|
||||
_k32.CloseHandle(err_w)
|
||||
|
||||
def _stream(handle) -> io.TextIOBase:
|
||||
fd = msvcrt.open_osfhandle(handle.value, os.O_RDONLY)
|
||||
return io.TextIOWrapper(io.FileIO(fd, "rb"), encoding=locale.getpreferredencoding(False),
|
||||
errors="replace")
|
||||
|
||||
return AppContainerProcess(pi.hProcess, pi.dwProcessId, _stream(out_r), _stream(err_r))
|
||||
|
||||
|
||||
__all__ = ["AppContainerProcess", "NetworkIsolationUnavailable", "PROFILE_NAME",
|
||||
"grant_access", "is_supported", "spawn"]
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Start a shell command that the operating system keeps off the network.
|
||||
|
||||
Used whenever "Block network" is on for agent ``run_command`` calls and for
|
||||
scheduled script tasks. The contract is fail-closed: if isolation cannot be
|
||||
set up, :class:`NetworkIsolationUnavailable` is raised and the caller refuses
|
||||
the command instead of running it with the network open.
|
||||
|
||||
* Windows: an AppContainer with no network capability
|
||||
(:mod:`.appcontainer_process`).
|
||||
* macOS: ``sandbox-exec`` with a profile that denies every network operation.
|
||||
* Linux: ``unshare --net`` in a new user namespace (an empty network namespace
|
||||
has only a downed loopback). If unprivileged namespaces are disabled,
|
||||
``unshare`` itself fails and the command never runs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .appcontainer_process import NetworkIsolationUnavailable
|
||||
|
||||
_MACOS_PROFILE = "(version 1)(allow default)(deny network*)"
|
||||
|
||||
|
||||
def spawn_without_network(command: str, cwd: Optional[str], env: Optional[Dict[str, str]]):
|
||||
"""A ``Popen``-like process running ``command`` through the shell, with no network."""
|
||||
if sys.platform == "win32":
|
||||
from . import appcontainer_process
|
||||
|
||||
return appcontainer_process.spawn(command, cwd, env)
|
||||
if sys.platform == "darwin" and shutil.which("sandbox-exec"):
|
||||
argv = ["sandbox-exec", "-p", _MACOS_PROFILE, "/bin/sh", "-c", command]
|
||||
elif sys.platform.startswith("linux") and shutil.which("unshare"):
|
||||
argv = ["unshare", "--user", "--map-root-user", "--net", "/bin/sh", "-c", command]
|
||||
else:
|
||||
raise NetworkIsolationUnavailable(
|
||||
"No network isolation is available on this system (needs AppContainer, "
|
||||
"sandbox-exec or unshare).")
|
||||
return subprocess.Popen(argv, cwd=cwd, env=env, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True, bufsize=1,
|
||||
start_new_session=True)
|
||||
|
||||
|
||||
__all__ = ["NetworkIsolationUnavailable", "spawn_without_network"]
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Tiny opt-in performance tracing helpers.
|
||||
|
||||
Tracing is disabled by default and emits only timings/counts, never prompts,
|
||||
credentials, file contents, or provider payloads.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
|
||||
_LOG = logging.getLogger("cowork.performance")
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return os.environ.get("COWORK_PERF_TRACE", "").strip().lower() in {"1", "true", "yes"}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def span(name: str, **fields):
|
||||
if not enabled():
|
||||
yield
|
||||
return
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
elapsed = (time.perf_counter() - started) * 1000.0
|
||||
safe = " ".join(f"{k}={v}" for k, v in fields.items())
|
||||
_LOG.info("perf %s %.1fms%s", name, elapsed, f" {safe}" if safe else "")
|
||||
@@ -17,7 +17,7 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.i18n import bind_dynamic, bind_tip, tr
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
@@ -57,7 +57,7 @@ class AudioRecorderWidget(QWidget):
|
||||
# Record / Stop toggle button
|
||||
self.record_btn = QPushButton()
|
||||
self.record_btn.setIcon(icon("microphone"))
|
||||
self.record_btn.setToolTip(tr("chat.record_audio_start") if tr("chat.record_audio_start") != "chat.record_audio_start" else "Record Voice Note")
|
||||
bind_dynamic(self.record_btn, self._sync_record_tip)
|
||||
self.record_btn.setFixedSize(32, 32)
|
||||
self.record_btn.clicked.connect(self.toggle_recording)
|
||||
layout.addWidget(self.record_btn)
|
||||
@@ -78,7 +78,7 @@ class AudioRecorderWidget(QWidget):
|
||||
|
||||
self.cancel_btn = QPushButton()
|
||||
self.cancel_btn.setIcon(icon("x"))
|
||||
self.cancel_btn.setToolTip("Cancel recording")
|
||||
bind_tip(self.cancel_btn, "chat.record_audio_cancel")
|
||||
self.cancel_btn.setFixedSize(24, 24)
|
||||
self.cancel_btn.clicked.connect(self.cancel_recording)
|
||||
status_layout.addWidget(self.cancel_btn)
|
||||
@@ -106,7 +106,7 @@ class AudioRecorderWidget(QWidget):
|
||||
self.timer_label.setText("00:00")
|
||||
self.status_container.setVisible(True)
|
||||
self.record_btn.setIcon(icon("square"))
|
||||
self.record_btn.setToolTip("Stop Recording")
|
||||
self._sync_record_tip()
|
||||
self.record_btn.setStyleSheet("background-color: #fca5a5; color: #991b1b;")
|
||||
self._timer.start()
|
||||
self.recording_started.emit()
|
||||
@@ -137,7 +137,12 @@ class AudioRecorderWidget(QWidget):
|
||||
self.status_container.setVisible(False)
|
||||
self.record_btn.setIcon(icon("microphone"))
|
||||
self.record_btn.setStyleSheet("")
|
||||
self.record_btn.setToolTip("Record Voice Note")
|
||||
self._sync_record_tip()
|
||||
|
||||
def _sync_record_tip(self) -> None:
|
||||
"""Tooltip nút ghi âm nói việc nó sẽ làm tiếp, theo trạng thái hiện tại."""
|
||||
self.record_btn.setToolTip(tr("chat.record_audio_stop" if self._is_recording
|
||||
else "chat.record_audio_start"))
|
||||
|
||||
def _on_tick(self) -> None:
|
||||
"""Update recording duration display every second."""
|
||||
|
||||
@@ -115,10 +115,23 @@ class ChatAgentsMixin:
|
||||
if err:
|
||||
self.status_message.emit(tr("chatpanel.agent_list_error", err=err))
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
self._agent_worker = w
|
||||
w.start()
|
||||
# Model discovery can involve a provider/network request. Constructing
|
||||
# the chat panel during startup must not wait for it; schedule it after
|
||||
# the first event-loop turn so the initial shell can paint immediately.
|
||||
from PySide6.QtCore import QTimer
|
||||
|
||||
if getattr(self, "_agent_refresh_pending", False):
|
||||
return
|
||||
self._agent_refresh_pending = True
|
||||
|
||||
def start_worker() -> None:
|
||||
self._agent_refresh_pending = False
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
self._agent_worker = w
|
||||
w.start()
|
||||
|
||||
QTimer.singleShot(0, start_worker)
|
||||
|
||||
def _populate_agents(self, models, keep: str) -> None:
|
||||
"""Đổ danh sách vào bộ chọn Agent.
|
||||
@@ -141,7 +154,7 @@ class ChatAgentsMixin:
|
||||
if not items and self.agent_combo.count() == 0:
|
||||
# No models found and none configured — placeholder with data=None so
|
||||
# we fall back to the provider's default model (never a fake name).
|
||||
self.agent_combo.addItem("(provider default)", None)
|
||||
self.agent_combo.addItem(tr("chat.provider_default_item"), None)
|
||||
keep_data = (f"{self._ADMIN_AGENT_PREFIX}{self._admin_agent.agent_id}"
|
||||
if getattr(self, "_admin_agent", None) is not None else keep)
|
||||
idx = self.agent_combo.findData(keep_data) if keep_data else -1
|
||||
|
||||
@@ -51,6 +51,8 @@ class MessageBubble(QFrame):
|
||||
super().__init__()
|
||||
self.role = role
|
||||
self._text = ""
|
||||
self._stream_pending = False
|
||||
self._render_count = 0
|
||||
self._collapsible = collapsible
|
||||
self._title = title
|
||||
self._head = None
|
||||
@@ -164,12 +166,20 @@ class MessageBubble(QFrame):
|
||||
def append_delta(self, delta: str) -> None:
|
||||
"""Nối thêm một mẩu văn bản đang phát dần từ model rồi vẽ lại dạng markdown."""
|
||||
self._text += delta
|
||||
self.set_markdown(self._text)
|
||||
if not self._stream_pending:
|
||||
self._stream_pending = True
|
||||
QTimer.singleShot(40, self.flush_stream)
|
||||
|
||||
def flush_stream(self) -> None:
|
||||
if self._stream_pending:
|
||||
self._stream_pending = False
|
||||
self.set_markdown(self._text)
|
||||
|
||||
def set_markdown(self, text: str) -> None:
|
||||
"""Đặt toàn bộ nội dung, hiển thị dạng markdown, rồi co giãn lại chiều cao."""
|
||||
self._text = text
|
||||
self.body.setMarkdown(text)
|
||||
self._render_count += 1
|
||||
self._autosize()
|
||||
if self._collapsible:
|
||||
self._update_head()
|
||||
@@ -393,4 +403,3 @@ class ChatView(QScrollArea):
|
||||
ChatHistoryWidget = ChatView
|
||||
|
||||
__all__ = ["ChatView", "ChatHistoryWidget", "MessageBubble"]
|
||||
|
||||
|
||||
@@ -99,6 +99,9 @@ class ChatPanel(ChatLiveTurnsMixin, ChatPanelLayoutMixin, ChatEventStreamMixin,
|
||||
# can run concurrently. Each value is a turn-context dict — see _start_turn.
|
||||
self.worker: AgentWorker | None = None
|
||||
self._active: Dict[AgentWorker, Dict[str, Any]] = {}
|
||||
# Người dùng đã bấm Dừng cho lượt đang chạy chưa. Cờ này chỉ đổi thứ
|
||||
# MÀN HÌNH nói, không đổi việc huỷ: huỷ vẫn là cờ trên worker.
|
||||
self._stop_requested = False
|
||||
self._turn_seq: int = 0
|
||||
# session_id -> its live messages list, for every conversation that still has
|
||||
# a turn running. Lets you start a new chat / reopen an old one WHILE work
|
||||
@@ -337,7 +340,10 @@ class ChatPanel(ChatLiveTurnsMixin, ChatPanelLayoutMixin, ChatEventStreamMixin,
|
||||
Switching chats, or hitting History → Refresh, shows whether THIS chat is
|
||||
still processing (a background turn) or idle."""
|
||||
if self._view_busy():
|
||||
self.thinking.start("chat.running") # this conversation is still working
|
||||
# Đã bấm Dừng mà lượt chưa kết thúc: giữ nhãn "đang dừng", đừng kéo
|
||||
# ngược về "đang chạy" — người dùng vừa bấm xong mà thấy chữ cũ thì
|
||||
# đọc ra là nút không ăn.
|
||||
self.thinking.start("chat.stopping" if self._stop_requested else "chat.running")
|
||||
else:
|
||||
self.thinking.stop()
|
||||
self.composer.set_running(bool(self._active)) # Stop shows while anything runs
|
||||
|
||||
@@ -11,9 +11,9 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
from ...core.worker import AgentWorker
|
||||
from ...i18n import tr
|
||||
from ...ui.dialog_buttons import confirm
|
||||
|
||||
|
||||
class ChatSessionMixin:
|
||||
@@ -308,7 +308,7 @@ class ChatSessionMixin:
|
||||
prompt = tr("chatpanel.delete_confirm_files", n=len(files), preview=preview)
|
||||
else:
|
||||
prompt = tr("chatpanel.delete_confirm_plain")
|
||||
if QMessageBox.question(self, tr("chatpanel.delete_confirm_title"), prompt) != QMessageBox.Yes:
|
||||
if not confirm(self, tr("chatpanel.delete_confirm_title"), prompt):
|
||||
return
|
||||
for bubble in turn.get("bubbles", []):
|
||||
bubble.setParent(None)
|
||||
|
||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from ...core.history import shorten_title
|
||||
from ...core.worker import AgentWorker
|
||||
from ...i18n import tr
|
||||
from ...state import AppContext
|
||||
@@ -88,7 +89,11 @@ class ChatTurnRunnerMixin:
|
||||
prefix = f"{prefix}\n\n{agent_prefix}" if prefix else agent_prefix
|
||||
if not self.title:
|
||||
base = text or (Path(attachments[0]).name if attachments else "(attachment)")
|
||||
self.title = (base[:60] + "…") if len(base) > 60 else base
|
||||
# Rút gọn mà không cắt vào giữa từ: xem shorten_title trong
|
||||
# core/history.py. Dùng chung với derive_title để tiêu đề trên
|
||||
# thanh tiêu đề và tiêu đề lưu vào lịch sử không rút gọn theo
|
||||
# hai kiểu khác nhau.
|
||||
self.title = shorten_title(base)
|
||||
self._notify_title()
|
||||
|
||||
# Reset the Plan panel so each message starts from a clean checklist (the
|
||||
@@ -212,6 +217,7 @@ class ChatTurnRunnerMixin:
|
||||
if self._view_busy() or len(self._active) >= self._max_parallel():
|
||||
self.composer.set_busy(True)
|
||||
self.status_message.emit(tr("chatpanel.working", name=tr(f"app.tab.{self.kind}")))
|
||||
self._stop_requested = False # lượt mới: xoá dấu vết lần Dừng trước
|
||||
self.thinking.start("chat.running")
|
||||
worker.start()
|
||||
|
||||
@@ -254,7 +260,7 @@ class ChatTurnRunnerMixin:
|
||||
self._show_usage(ctx) # per-turn + conversation token/cost
|
||||
except Exception: # noqa: BLE001 — usage display must never break a turn
|
||||
pass
|
||||
done = self.chat_view.add_success(tr("chat.done_marker")) # green done marker in the chat box
|
||||
done = self._dau_ket_thuc()
|
||||
folder = self.workspace_dir()
|
||||
if folder:
|
||||
done.add_folder_link(str(folder), tr("chat.open_output_folder"))
|
||||
@@ -268,12 +274,32 @@ class ChatTurnRunnerMixin:
|
||||
self._maybe_notify_teams(result)
|
||||
self._drain_queue()
|
||||
|
||||
def _dau_ket_thuc(self):
|
||||
"""Dấu kết thúc đặt vào khung chat khi một lượt vừa xong.
|
||||
|
||||
Dừng theo yêu cầu KHÔNG phải là hoàn thành: dấu xanh "Đã hoàn thành" ở
|
||||
đó nói ngược hẳn với thứ người dùng vừa làm, và là lý do người dùng báo
|
||||
"bấm Dừng mà không biết nó đã dừng hay chưa".
|
||||
|
||||
Tách khỏi ``_on_finished`` để nhánh này kiểm được bằng test mà không
|
||||
phải dựng cả một ``ChatPanel``.
|
||||
"""
|
||||
if self._stop_requested:
|
||||
return self.chat_view.add_status(tr("chat.stopped_marker"))
|
||||
return self.chat_view.add_success(tr("chat.done_marker"))
|
||||
|
||||
def _on_failed(self, ctx: Dict[str, Any], err: str) -> None:
|
||||
"""Lượt chạy lỗi: huỷ thư mục kết quả tạm và hiện lỗi (nếu hội thoại còn đang mở)."""
|
||||
live = self._turn_is_live(ctx)
|
||||
self._end_turn(ctx)
|
||||
self._cleanup_turn(ctx, False) # discard this turn's output sandbox
|
||||
if live:
|
||||
if live and self._stop_requested:
|
||||
# Người dùng vừa bấm Dừng: mọi lỗi phát sinh trong lúc huỷ là hệ quả
|
||||
# của chính việc huỷ (đóng socket giữa stream, tool bị cắt ngang).
|
||||
# Dội một traceback đỏ vào mặt họ là trả lời sai câu hỏi "nó dừng
|
||||
# chưa?" — thứ họ cần là một dòng nói rõ là đã dừng.
|
||||
self._dau_ket_thuc()
|
||||
elif live:
|
||||
self.chat_view.add_error(err)
|
||||
self.graph_event.emit(self.session_name, {"type": "error", "content": err})
|
||||
from ...providers.base import is_model_not_found_error
|
||||
@@ -286,7 +312,10 @@ class ChatTurnRunnerMixin:
|
||||
self.composer.set_text(ctx["display_text"])
|
||||
else:
|
||||
self._persist_session(ctx)
|
||||
self.status_message.emit(tr("chatpanel.failed", name=tr(f"app.tab.{self.kind}")))
|
||||
# Thanh trạng thái phải nói cùng một chuyện với khung chat: dừng theo
|
||||
# yêu cầu thì không phải "lỗi".
|
||||
key = "chatpanel.stopped" if self._stop_requested else "chatpanel.failed"
|
||||
self.status_message.emit(tr(key, name=tr(f"app.tab.{self.kind}")))
|
||||
self.turn_finished.emit({"error": err})
|
||||
self._drain_queue()
|
||||
|
||||
@@ -311,6 +340,11 @@ class ChatTurnRunnerMixin:
|
||||
"""Dừng mọi lượt đang chạy của hội thoại này và xoá sạch hàng đợi."""
|
||||
if not self._active:
|
||||
return
|
||||
# Báo NGAY trên màn: huỷ thật có thể mất vài giây (đang chờ gateway trả
|
||||
# lời, đang chạy dở một tool), mà trong lúc đó chỉ báo vẫn đếm "Đang
|
||||
# chạy · 160s" — người dùng đọc ra là nút Dừng không ăn.
|
||||
self._stop_requested = True
|
||||
self.thinking.set_label("chat.stopping")
|
||||
for w in list(self._active):
|
||||
if w.isRunning():
|
||||
w.request_stop()
|
||||
|
||||
@@ -34,6 +34,7 @@ from PySide6.QtGui import QBrush, QColor, QPainterPath, QPen, QPolygonF
|
||||
from PySide6.QtWidgets import QGraphicsItem, QGraphicsObject, QGraphicsPathItem, QMenu
|
||||
|
||||
from ...core.co4e import STEP_DONE, STEP_ERROR, STEP_PLANNED, STEP_RUNNING, Edge, Node
|
||||
from ...i18n import tr
|
||||
from ...theme import current_palette
|
||||
from .canvas_geometry import _elide, _rounded_path
|
||||
|
||||
@@ -199,6 +200,14 @@ class _NodeItem(QGraphicsObject):
|
||||
e.accept()
|
||||
return
|
||||
super().mousePressEvent(e)
|
||||
if self.isSelected():
|
||||
# itemChange() only emits node_selected when the SELECTION STATE
|
||||
# actually flips (ItemSelectedHasChanged) — clicking a node that
|
||||
# was already selected (e.g. left selected when a run started)
|
||||
# never re-fires it, so the property panel silently kept showing
|
||||
# stale data and looked "locked" while the node ran. Emit
|
||||
# explicitly on every click so the panel always reloads.
|
||||
self.canvas.node_selected.emit(self.node.id)
|
||||
|
||||
def mouseMoveEvent(self, e):
|
||||
"""Rê chuột trong lúc kéo nối: vẽ lại đường nét đứt theo con trỏ."""
|
||||
@@ -225,9 +234,9 @@ class _NodeItem(QGraphicsObject):
|
||||
def contextMenuEvent(self, e):
|
||||
"""Menu chuột phải trên node: thêm bước kế, nối từ đây, xoá bước."""
|
||||
menu = QMenu()
|
||||
a_add = menu.addAction("+ Add next step")
|
||||
a_conn = menu.addAction("→ Connect from here")
|
||||
a_del = menu.addAction("🗑 Delete step")
|
||||
a_add = menu.addAction("+ " + tr("co4e.canvas_add_next"))
|
||||
a_conn = menu.addAction("→ " + tr("co4e.canvas_connect_from"))
|
||||
a_del = menu.addAction("🗑 " + tr("co4e.delete_step"))
|
||||
chosen = menu.exec(e.screenPos())
|
||||
if chosen is a_add:
|
||||
self.canvas.add_step_below(self.node.id)
|
||||
@@ -334,7 +343,7 @@ class _EdgeItem(QGraphicsPathItem):
|
||||
def contextMenuEvent(self, e):
|
||||
"""Menu chuột phải trên đường nối: xoá liên kết."""
|
||||
menu = QMenu()
|
||||
act_del = menu.addAction("🗑 Delete connection")
|
||||
act_del = menu.addAction("🗑 " + tr("co4e.canvas_delete_edge"))
|
||||
if menu.exec(e.screenPos()) is act_del:
|
||||
self.canvas.delete_edge(self.edge)
|
||||
e.accept()
|
||||
|
||||
@@ -273,6 +273,15 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
|
||||
item.status = status
|
||||
item.update()
|
||||
|
||||
def node_status(self, node_id: str) -> str:
|
||||
"""Trạng thái chạy hiện tại của một node — "idle" nếu không tìm thấy.
|
||||
|
||||
Dùng để quyết định có khóa bảng thuộc tính bên phải hay không khi
|
||||
người dùng chọn node (xem ``StepConfigPanel.set_locked``).
|
||||
"""
|
||||
item = self._nodes.get(node_id)
|
||||
return item.status if item is not None else "idle"
|
||||
|
||||
def reset_statuses(self) -> None:
|
||||
"""Đưa mọi node về trạng thái chờ — gọi trước mỗi lần chạy lại luồng."""
|
||||
for it in self._nodes.values():
|
||||
|
||||
@@ -101,6 +101,11 @@ class Co4EFlowTabsMixin:
|
||||
self.center_stack.setCurrentIndex(1)
|
||||
self._sync_runs_toggle(False)
|
||||
self._apply_workflow(self._flows[flow_idx])
|
||||
# _apply_workflow() rebuilds the canvas from wf.nodes/edges, which
|
||||
# resets every node's live status to "idle" — without this, coming
|
||||
# back to a flow that's still running (e.g. from the Runs page)
|
||||
# shows every node as idle even though it's actually mid-run.
|
||||
self._reflect_active_run(self._flows[flow_idx].id)
|
||||
def _sync_runs_toggle(self, on: bool) -> None:
|
||||
"""Keep the Runs toggle showing which page is up, however it got there
|
||||
(a double-click in the runs table also switches pages)."""
|
||||
|
||||
@@ -13,10 +13,11 @@ import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtWidgets import QInputDialog, QMenu, QMessageBox, QTableWidget, QTableWidgetItem
|
||||
from PySide6.QtWidgets import QMenu, QMessageBox, QTableWidget, QTableWidgetItem
|
||||
from ...core import co4e
|
||||
from ...i18n import tr
|
||||
from ...theme import current_palette
|
||||
from .co4e_workflow_crud import _LOCKED_NODE_STATUSES
|
||||
|
||||
|
||||
class Co4ERunsMixin:
|
||||
@@ -155,7 +156,14 @@ class Co4ERunsMixin:
|
||||
t = ev.get("type")
|
||||
if t == "node_status":
|
||||
if shown:
|
||||
self.canvas.update_node_status(ev.get("node_id"), ev.get("status"))
|
||||
nid = ev.get("node_id")
|
||||
self.canvas.update_node_status(nid, ev.get("status"))
|
||||
# If the panel is showing THIS node right now (e.g. it was
|
||||
# idle and the user had it open when the run started), keep
|
||||
# the lock in sync instead of waiting for the next click.
|
||||
if nid == getattr(self.config, "_node_id", None):
|
||||
self.config.set_locked(
|
||||
self.canvas.node_status(nid) in _LOCKED_NODE_STATUSES)
|
||||
elif t == "node_output":
|
||||
if run_wf is not None:
|
||||
self._outputs_for(run_wf)[ev["node_id"]] = ev.get("output", "")
|
||||
@@ -329,9 +337,9 @@ class Co4ERunsMixin:
|
||||
h = self.manager.get(run_id)
|
||||
if h is None:
|
||||
return
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
new, ok = QInputDialog.getText(self, tr("co4e.rename_run"),
|
||||
tr("co4e.rename_run_label"), text=h.name)
|
||||
from ...ui.dialog_buttons import ask_text
|
||||
new, ok = ask_text(self, tr("co4e.rename_run"),
|
||||
tr("co4e.rename_run_label"), text=h.name)
|
||||
new = (new or "").strip()
|
||||
if not ok or not new or new == h.name:
|
||||
return
|
||||
|
||||
@@ -8,12 +8,16 @@ from __future__ import annotations
|
||||
import re
|
||||
from typing import List, Optional
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtWidgets import QInputDialog, QMenu
|
||||
from PySide6.QtWidgets import QMenu
|
||||
from ...core import co4e
|
||||
from ...core.co4e import STEP_RUNNING
|
||||
from ...i18n import tr
|
||||
from ...ui.dialog_buttons import ask_text
|
||||
from ...ui.icons import icon
|
||||
from ...presentation.co4e.co4e_chat_view import _skill_names
|
||||
|
||||
_LOCKED_NODE_STATUSES = (STEP_RUNNING,)
|
||||
|
||||
|
||||
class Co4EWorkflowCrudMixin:
|
||||
"""Phần tạo/mở/lưu/xoá luồng của Co4E Studio.
|
||||
@@ -115,8 +119,8 @@ class Co4EWorkflowCrudMixin:
|
||||
wf = co4e.get_workflow(ident)
|
||||
if wf is None:
|
||||
return
|
||||
name, ok = QInputDialog.getText(self, tr("co4e.rename"), tr("co4e.rename_prompt"),
|
||||
text=wf.name)
|
||||
name, ok = ask_text(self, tr("co4e.rename"), tr("co4e.rename_prompt"),
|
||||
text=wf.name)
|
||||
name = (name or "").strip()
|
||||
if not ok or not name:
|
||||
return
|
||||
@@ -163,10 +167,15 @@ class Co4EWorkflowCrudMixin:
|
||||
self.canvas.add_palette_step(co4e.Step(label="New Step"),
|
||||
self.canvas.mapToScene(self.canvas.rect().center()))
|
||||
def _on_node_selected(self, node_id: str) -> None:
|
||||
"""Chọn một node thì nạp bước đó vào bảng thuộc tính, tự mở bảng nếu đang gập."""
|
||||
"""Chọn một node thì nạp bước đó vào bảng thuộc tính, tự mở bảng nếu đang gập.
|
||||
|
||||
Chỉ khoá ô nhập liệu khi bước ĐANG chạy (DF-002) — chạy xong rồi thì
|
||||
vẫn sửa lại được bình thường.
|
||||
"""
|
||||
for n in self.canvas.nodes():
|
||||
if n.id == node_id:
|
||||
self.config.load_step(node_id, n.data, _skill_names())
|
||||
self.config.set_locked(self.canvas.node_status(node_id) in _LOCKED_NODE_STATUSES)
|
||||
if self._config_collapsed:
|
||||
self._toggle_config()
|
||||
return
|
||||
|
||||
@@ -24,20 +24,21 @@ thứ tự kế thừa không ảnh hưởng hành vi (khác trường hợp
|
||||
``co4e_canvas_widget.py``, nơi thứ tự mixin-trước-Qt-base là bắt buộc vì có
|
||||
override trùng tên).
|
||||
|
||||
Import trong từng method giữ nguyên y hệt bản gốc (kể cả các import cục bộ có
|
||||
vẻ thừa như ``from PySide6.QtWidgets import QInputDialog`` lặp lại bên trong
|
||||
``_add_subagent``/``_edit_subagent`` dù đã có ở top-level) — chỉ số cấp `..`
|
||||
được nâng lên `...` cho khớp việc file dời từ ``ui/`` (cách gốc 2 cấp) sang
|
||||
``presentation/co4e/`` (cách gốc 3 cấp).
|
||||
Import trong từng method giữ nguyên y hệt bản gốc — chỉ số cấp `..` được nâng
|
||||
lên `...` cho khớp việc file dời từ ``ui/`` (cách gốc 2 cấp) sang
|
||||
``presentation/co4e/`` (cách gốc 3 cấp). Riêng các lời gọi ``QInputDialog``
|
||||
đã chuyển sang ``ui.dialog_buttons``: hàm tĩnh của Qt tự dựng hộp thoại bên
|
||||
trong nên nút "Cancel" của nó luôn là tiếng Anh.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from PySide6.QtWidgets import QInputDialog, QListWidgetItem
|
||||
from PySide6.QtWidgets import QListWidgetItem
|
||||
|
||||
from ...core.co4e import SubAgent
|
||||
from ...i18n import tr
|
||||
from ...ui.dialog_buttons import ask_item, ask_multiline, ask_text
|
||||
|
||||
|
||||
class _StepConfigActionsMixin:
|
||||
@@ -67,14 +68,12 @@ class _StepConfigActionsMixin:
|
||||
"""Thêm một sub-agent vào bước đang chọn (chạy song song trong bước đó)."""
|
||||
if self._step is None:
|
||||
return
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
|
||||
names = self._available_agent_names()
|
||||
if names:
|
||||
name, ok = QInputDialog.getItem(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"),
|
||||
names, 0, True) # editable: can type a new one
|
||||
name, ok = ask_item(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"),
|
||||
names, 0, True) # editable: can type a new one
|
||||
else:
|
||||
name, ok = QInputDialog.getText(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"))
|
||||
name, ok = ask_text(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"))
|
||||
name = (name or "").strip()
|
||||
if not ok or not name:
|
||||
return
|
||||
@@ -89,13 +88,11 @@ class _StepConfigActionsMixin:
|
||||
row = self.sub_list.row(item)
|
||||
if not (0 <= row < len(self._step.sub_agents)):
|
||||
return
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
|
||||
names = self._available_agent_names()
|
||||
cur = self._step.sub_agents[row].agent
|
||||
start = names.index(cur) if cur in names else 0
|
||||
name, ok = QInputDialog.getItem(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"),
|
||||
names or [cur], start, True)
|
||||
name, ok = ask_item(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"),
|
||||
names or [cur], start, True)
|
||||
name = (name or "").strip()
|
||||
if ok and name:
|
||||
self._step.sub_agents[row].agent = name
|
||||
@@ -151,7 +148,7 @@ class _StepConfigActionsMixin:
|
||||
role = self.role_edit.text().strip()
|
||||
if not name and not role:
|
||||
return
|
||||
hint, ok = QInputDialog.getMultiLineText(
|
||||
hint, ok = ask_multiline(
|
||||
self, tr("co4e.ai_draft_hint_title"), tr("co4e.ai_draft_hint_label"))
|
||||
if not ok:
|
||||
return
|
||||
|
||||
@@ -38,7 +38,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from ...config import PROVIDER_LABELS
|
||||
from ...core.co4e import PERMISSION_PRESETS, Step
|
||||
from ...core.co4e import PERMISSION_PRESETS, STEP_DONE, STEP_RUNNING, Step
|
||||
from ...i18n import bind_items, bind_placeholder, bind_text, bind_tip, tr
|
||||
from ...ui.icons import icon, icon_picker_combo
|
||||
from .node_property_actions_mixin import _StepConfigActionsMixin
|
||||
@@ -65,6 +65,8 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
||||
self._step: Optional[Step] = None
|
||||
self._node_id = ""
|
||||
self._loading = False
|
||||
self._ctx_available = ctx is not None
|
||||
self._locked = False
|
||||
self.setWidgetResizable(True)
|
||||
host = QWidget()
|
||||
self.setWidget(host)
|
||||
@@ -77,7 +79,7 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
||||
# (skills/files/sub-agents). No tabs/accordion: every group's border
|
||||
# and heading are what separate it from its neighbours, and all three
|
||||
# are on screen (or one scroll away) at once.
|
||||
form, _basic_card = _add_section(outer, tr("co4e.tab_basic"))
|
||||
form, _basic_card = _add_section(outer, "co4e.tab_basic")
|
||||
|
||||
self.label_edit = QLineEdit()
|
||||
self.label_edit.textChanged.connect(self._on_edit)
|
||||
@@ -122,7 +124,7 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
||||
self.context_edit.textChanged.connect(self._on_edit)
|
||||
form.addRow(bind_text(QLabel(), "co4e.f_context"), self.context_edit)
|
||||
|
||||
form2, _model_card = _add_section(outer, tr("co4e.tab_model_perm"))
|
||||
form2, _model_card = _add_section(outer, "co4e.tab_model_perm")
|
||||
|
||||
model_row = QHBoxLayout()
|
||||
self.model_combo = QComboBox()
|
||||
@@ -161,7 +163,7 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
||||
vrow = QWidget(); vrow.setLayout(verify_row)
|
||||
form2.addRow("", vrow)
|
||||
|
||||
form3, _skills_card = _add_section(outer, tr("co4e.tab_skills_files"))
|
||||
form3, _skills_card = _add_section(outer, "co4e.tab_skills_files")
|
||||
|
||||
# Skills checklist (registry skills)
|
||||
self.skills_list = QListWidget()
|
||||
@@ -191,7 +193,7 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
||||
# Skills & Tệp, since it's really a distinct group, just one that
|
||||
# only applies to parallel-variant steps. load_step() hides the whole
|
||||
# card for a non-parallel step (see is_par below).
|
||||
form4, self._parallel_card = _add_section(outer, tr("co4e.f_subagents"))
|
||||
form4, self._parallel_card = _add_section(outer, "co4e.f_subagents")
|
||||
self.sub_list = QListWidget()
|
||||
self.sub_list.setMaximumHeight(90)
|
||||
self.sub_list.itemDoubleClicked.connect(self._edit_subagent) # re-pick agent
|
||||
@@ -289,6 +291,27 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
||||
self.sub_list.addItem(sub.agent)
|
||||
self._loading = False
|
||||
|
||||
def set_locked(self, locked: bool) -> None:
|
||||
"""Khoá/mở khoá các trường chỉnh sửa theo trạng thái chạy của bước.
|
||||
|
||||
Chỉ khoá khi bước ĐANG chạy — tránh sửa nhầm cấu hình trong lúc chưa
|
||||
biết kết quả (DF-002: trước đây còn khoá cả bước đã chạy xong, khiến
|
||||
không sửa lại được sau khi run xong). Nút Chạy/Chạy từ đây/Xoá bước
|
||||
vẫn hoạt động bình thường khi khoá — chỉ ô nhập liệu bị khoá, không
|
||||
phải cả panel.
|
||||
"""
|
||||
self._locked = locked
|
||||
editable = not locked
|
||||
for w in (self.label_edit, self.role_edit, self.icon_edit,
|
||||
self.instructions_edit, self.context_edit,
|
||||
self.model_combo, self.perm_combo, self.verify_chk,
|
||||
self.rounds_spin, self.skills_list,
|
||||
self.attach_add_btn, self.attach_del_btn,
|
||||
self.sub_add_btn, self.sub_del_btn, self.sub_list):
|
||||
w.setEnabled(editable)
|
||||
self.gen_btn.setEnabled(editable and self._ctx_available)
|
||||
self.load_models_btn.setEnabled(editable and self._ctx_available)
|
||||
|
||||
def clear_step(self) -> None:
|
||||
"""Xoá bảng khi không có bước nào được chọn."""
|
||||
self._step = None
|
||||
|
||||
@@ -26,6 +26,7 @@ from __future__ import annotations
|
||||
from PySide6.QtCore import Qt, QEasingCurve, QPropertyAnimation, Signal
|
||||
from PySide6.QtWidgets import QFormLayout, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from ...i18n import bind_dynamic, tr
|
||||
from ...theme import current_palette
|
||||
|
||||
_SECTION_ANIM_MS = 180
|
||||
@@ -64,7 +65,7 @@ class _SectionHeader(QLabel):
|
||||
super().showEvent(event)
|
||||
|
||||
|
||||
def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
|
||||
def _add_section(outer: QVBoxLayout, title_key: str) -> tuple[QFormLayout, QWidget]:
|
||||
"""One group of fields, collapsed to just its heading by default and
|
||||
independently expandable, so a long step config reads as a short list of
|
||||
group names until you open the one you need. Deliberately bare — no card
|
||||
@@ -74,7 +75,11 @@ def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
|
||||
the group's rows to ``form``; ``card`` is the whole section (header +
|
||||
body) — hide it to remove the group entirely (e.g. for a section that
|
||||
only applies to some steps), rather than hiding individual rows inside
|
||||
an always-visible header."""
|
||||
an always-visible header.
|
||||
|
||||
Nhận KHOÁ dịch, không nhận chuỗi đã dịch: nhãn mục do hàm này tự dựng nên
|
||||
nơi gọi không giữ tham chiếu nào để áp lại: truyền ``tr(...)`` vào đây thì
|
||||
bốn tiêu đề đứng nguyên ở ngôn ngữ lúc dựng panel."""
|
||||
p = current_palette()
|
||||
card = QWidget()
|
||||
card_lay = QVBoxLayout(card)
|
||||
@@ -96,7 +101,6 @@ def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
|
||||
# diacritics.
|
||||
header.ensurePolished()
|
||||
header.setFixedHeight(header.fontMetrics().height())
|
||||
header.setText(f"▶ {title}")
|
||||
card_lay.addWidget(header)
|
||||
|
||||
body = QWidget()
|
||||
@@ -112,6 +116,14 @@ def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
|
||||
|
||||
is_open = False
|
||||
|
||||
def _sync_header() -> None:
|
||||
"""Nhãn mục: dấu gập/mở hiện tại + tiêu đề theo ngôn ngữ đang chọn."""
|
||||
header.setText(f"{'▼' if is_open else '▶'} {tr(title_key)}")
|
||||
|
||||
# Ràng buộc ĐỘNG chứ không bind cứng một chuỗi: nhãn này mang cả trạng thái
|
||||
# gập/mở, nên bind cứng sẽ trả nó về ▶ mỗi lần người dùng đổi ngôn ngữ.
|
||||
bind_dynamic(header, _sync_header)
|
||||
|
||||
def _on_finished() -> None:
|
||||
"""Hiệu ứng gập/mở chạy xong: bỏ trần chiều cao khi đang mở, để bước có nhiều
|
||||
trường không bị cắt cụt.
|
||||
@@ -130,7 +142,7 @@ def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
|
||||
"""Lật trạng thái gập/mở của một mục và chạy hiệu ứng tương ứng."""
|
||||
nonlocal is_open
|
||||
is_open = not is_open
|
||||
header.setText(f"{'▼' if is_open else '▶'} {title}")
|
||||
_sync_header()
|
||||
anim.stop()
|
||||
if is_open:
|
||||
body.setVisible(True)
|
||||
|
||||
@@ -116,9 +116,9 @@ class HabitsWidget(QWidget):
|
||||
"""Apply an AI-suggested cost-saving strategy AFTER the user
|
||||
approves: turn on auto-compress and compress earlier (lower
|
||||
threshold) + compress content before sending it to the agent."""
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
if QMessageBox.question(self, tr("dashboard.strategy_title"),
|
||||
tr("dashboard.strategy_confirm")) != QMessageBox.Yes:
|
||||
from ...ui.dialog_buttons import confirm
|
||||
if not confirm(self, tr("dashboard.strategy_title"),
|
||||
tr("dashboard.strategy_confirm")):
|
||||
return
|
||||
cx = self.ctx.config.data.setdefault("context", {})
|
||||
cx["auto_compact"] = True
|
||||
|
||||
@@ -290,9 +290,9 @@ class AiEditPipeline:
|
||||
self.pending = None
|
||||
self._owner.show_confirm_row(False)
|
||||
if p.get("image_gens"):
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
if QMessageBox.question(self._owner, tr("folder.ai_image_confirm_title"),
|
||||
tr("folder.ai_image_confirm_gen")) != QMessageBox.Yes:
|
||||
from ...ui.dialog_buttons import confirm
|
||||
if not confirm(self._owner, tr("folder.ai_image_confirm_title"),
|
||||
tr("folder.ai_image_confirm_gen")):
|
||||
self._owner.status_message.emit(tr("folder.ai_image_declined"))
|
||||
return
|
||||
self._generate_then_finalize(p)
|
||||
|
||||
@@ -21,9 +21,9 @@ from __future__ import annotations
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget,
|
||||
QComboBox, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget,
|
||||
)
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.folder.ai_edit_model_resolver import AiEditModelResolver
|
||||
@@ -32,6 +32,45 @@ from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.chat_view import ChatView
|
||||
|
||||
|
||||
class _AutoExpandInput(QPlainTextEdit):
|
||||
"""Instruction box: grows with content (1..~6 lines, then scrolls), Enter
|
||||
submits, Shift+Enter inserts a newline — same convention as the Cowork
|
||||
composer (``presentation/chat/chat_input_box.py::_Input``), minus its
|
||||
``/skill``/``/agent`` popups and drag-drop attachment handling, which
|
||||
don't apply to a single AI-edit instruction. DF-008: a fixed-height
|
||||
single-line ``QLineEdit`` read as cramped for a full instruction; this
|
||||
replaces it instead of just nudging the height up further."""
|
||||
|
||||
submit = Signal()
|
||||
|
||||
MIN_HEIGHT = 36 # matches the old QLineEdit's bumped-up height
|
||||
MAX_HEIGHT = 140 # ~6 lines, then it scrolls instead of growing further
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setTabChangesFocus(True) # Tab moves focus, doesn't insert a tab
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.textChanged.connect(self._adjust_height)
|
||||
self._adjust_height()
|
||||
|
||||
def _adjust_height(self) -> None:
|
||||
# QPlainTextEdit reports the document height in LINES, not pixels —
|
||||
# convert via line spacing (same approach as chat_input_box.py).
|
||||
lines = self.document().size().height() or 1
|
||||
line_px = self.fontMetrics().lineSpacing()
|
||||
h = int(lines * line_px + 2 * self.frameWidth() + 12)
|
||||
h = max(self.MIN_HEIGHT, min(self.MAX_HEIGHT, h))
|
||||
if h != self.height():
|
||||
self.setFixedHeight(h)
|
||||
|
||||
def keyPressEvent(self, e) -> None: # noqa: N802
|
||||
if e.key() in (Qt.Key_Return, Qt.Key_Enter) and not (e.modifiers() & Qt.ShiftModifier):
|
||||
self.submit.emit()
|
||||
return
|
||||
super().keyPressEvent(e)
|
||||
|
||||
|
||||
class AiFileEditorDialog(QWidget):
|
||||
"""Collapsible panel: a Cowork-style inline chat timeline, this panel's
|
||||
OWN model picker + routing toggle, an instruction box, and an Apply/
|
||||
@@ -94,9 +133,9 @@ class AiFileEditorDialog(QWidget):
|
||||
ctx, self.ai_model_combo, self.ai_chat.add_status, self._confirm_routing_switch)
|
||||
|
||||
row = QHBoxLayout()
|
||||
self.ai_input = QLineEdit()
|
||||
self.ai_input = _AutoExpandInput()
|
||||
self.ai_input.setPlaceholderText(tr("folder.ai_placeholder"))
|
||||
self.ai_input.returnPressed.connect(self._ai_send)
|
||||
self.ai_input.submit.connect(self._ai_send)
|
||||
row.addWidget(self.ai_input, 1)
|
||||
self.ai_send_btn = QPushButton(tr("folder.ai_send"))
|
||||
self.ai_send_btn.setObjectName("primary")
|
||||
@@ -170,7 +209,7 @@ class AiFileEditorDialog(QWidget):
|
||||
if not self.preview.root:
|
||||
self.ai_chat.add_error(tr("folder.ai_no_file"))
|
||||
return
|
||||
instruction = self.ai_input.text().strip()
|
||||
instruction = self.ai_input.toPlainText().strip()
|
||||
if not instruction:
|
||||
return
|
||||
self.ai_input.clear()
|
||||
|
||||
@@ -110,7 +110,10 @@ class OfficeDocumentRenderer:
|
||||
if self._engine is None:
|
||||
try:
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
|
||||
from .offline_web_page import install_offline_page
|
||||
self._engine = QWebEngineView()
|
||||
install_offline_page(self._engine)
|
||||
self._owner.stack.addWidget(self._engine)
|
||||
except Exception: # noqa: BLE001
|
||||
self._engine = None
|
||||
@@ -276,10 +279,9 @@ class OfficeDocumentRenderer:
|
||||
from cowork_local.core import pptx_edit
|
||||
o = self._owner
|
||||
if not skip_confirm and pptx_edit.image_change_requested(content):
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
ok = QMessageBox.question(o, tr("folder.ai_image_confirm_title"),
|
||||
tr("folder.ai_image_confirm"))
|
||||
if ok != QMessageBox.Yes:
|
||||
from cowork_local.ui.dialog_buttons import confirm
|
||||
if not confirm(o, tr("folder.ai_image_confirm_title"),
|
||||
tr("folder.ai_image_confirm")):
|
||||
o.status_message.emit(tr("folder.ai_image_declined"))
|
||||
return False
|
||||
pptx_edit.apply_text_to_pptx(o.current_file, content)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""HTML preview page that loads nothing from the web while "Block network" is on.
|
||||
|
||||
``QWebEngineView.setHtml`` happily fetches every ``<img src="https://...">``,
|
||||
``<script src>`` and stylesheet the previewed file references — an outbound
|
||||
connection the user never asked for, made by the app itself. The preview gets
|
||||
its own off-the-record profile (so the interceptor below touches no other web
|
||||
view, e.g. the GraphRAG renderer) and every remote request is refused while
|
||||
the switch is on. Local files, ``data:`` and ``qrc:`` URLs still load.
|
||||
|
||||
Lifetime rule: a ``QWebEngineProfile`` must outlive every page that uses it.
|
||||
A profile owned by the view is destroyed *before* the page (children die in
|
||||
creation order) — Qt then warns "Release of profile requested but
|
||||
WebEnginePage still not deleted" and the app can abort later (0xc0000409 in
|
||||
Qt6Core.dll, seen when switching tabs). So there is ONE profile for the whole
|
||||
app, owned by the QApplication, and each page is owned by its view.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtWebEngineCore import (
|
||||
QWebEnginePage, QWebEngineProfile, QWebEngineSettings, QWebEngineUrlRequestInterceptor,
|
||||
)
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from cowork_local.application.network import network_guard
|
||||
|
||||
_REMOTE_SCHEMES = frozenset({"http", "https", "ws", "wss", "ftp"})
|
||||
_profile: Optional[QWebEngineProfile] = None
|
||||
_blocker: Optional["RemoteRequestBlocker"] = None # Python must hold it, or it is collected
|
||||
|
||||
|
||||
class RemoteRequestBlocker(QWebEngineUrlRequestInterceptor):
|
||||
"""Refuses remote URLs whenever the network guard says the network is blocked."""
|
||||
|
||||
def interceptRequest(self, info) -> None: # noqa: N802 - Qt override
|
||||
"""Called by WebEngine for every request the page makes."""
|
||||
if info.requestUrl().scheme().lower() in _REMOTE_SCHEMES and network_guard.is_blocked():
|
||||
info.block(True)
|
||||
|
||||
|
||||
def preview_profile() -> QWebEngineProfile:
|
||||
"""The app-wide off-the-record profile shared by every HTML preview."""
|
||||
global _profile, _blocker
|
||||
if _profile is None:
|
||||
_profile = QWebEngineProfile(QApplication.instance()) # no storage name = off the record
|
||||
_blocker = RemoteRequestBlocker(_profile)
|
||||
_profile.setUrlRequestInterceptor(_blocker)
|
||||
_profile.settings().setAttribute(
|
||||
QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, True)
|
||||
return _profile
|
||||
|
||||
|
||||
def install_offline_page(view) -> None:
|
||||
"""Give ``view`` a page on the shared preview profile.
|
||||
|
||||
The previewed file is loaded with a ``file://`` base URL, and Qt refuses
|
||||
every remote resource of such a page unless
|
||||
``LocalContentCanAccessRemoteUrls`` is on — so web images never showed,
|
||||
even with the network open. The switch is turned on for the profile; the
|
||||
interceptor is what keeps remote content out while "Block network" is on.
|
||||
"""
|
||||
view.setPage(QWebEnginePage(preview_profile(), view))
|
||||
|
||||
|
||||
__all__ = ["RemoteRequestBlocker", "install_offline_page", "preview_profile"]
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Khoá phạm vi quét của màn GraphRAG vào một project.
|
||||
|
||||
Tách khỏi ``graph_renderer.py``: file đó đã ở 399/400 dòng — đúng một dòng
|
||||
trước trần của ``scripts/check_loc.py``, và cổng ấy nói rõ cách duy nhất đúng
|
||||
khi chạm trần là tách file, không phải nới con số. Khối này là chỗ tự nhiên
|
||||
để cắt: ba phương thức dưới đây chỉ nói về một việc — project nào đang khoá,
|
||||
và thư mục nào đi theo nó — còn phần còn lại của renderer lo việc vẽ.
|
||||
|
||||
Là mixin chứ không phải đối tượng rời, cùng lý do như ``NavRailMixin``: ba
|
||||
phương thức này đọc/ghi state của chính renderer (``project_combo``,
|
||||
``path_edit``, ``_needs_scan``…). Biến thành đối tượng cộng tác thì phải viết
|
||||
lại từng chỗ ``self.X`` thành ``self.renderer.X`` mà không đổi hành vi gì.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.i18n import tr
|
||||
|
||||
|
||||
class GraphProjectLockMixin:
|
||||
"""Ba phương thức khoá-theo-project. Trộn vào ``GraphRenderer``."""
|
||||
|
||||
def _refresh_project_combo(self) -> None:
|
||||
"""Nạp lại danh sách project vào bộ chọn, giữ nguyên project đang chọn."""
|
||||
from cowork_local.core.projects import list_projects
|
||||
|
||||
keep = self._active_project_id
|
||||
self.project_combo.blockSignals(True)
|
||||
self.project_combo.clear()
|
||||
self.project_combo.addItem(tr("structure.project_none"), "")
|
||||
row_to_select = 0
|
||||
for i, p in enumerate(list_projects(), start=1):
|
||||
self.project_combo.addItem(p.name, p.project_id)
|
||||
if p.project_id == keep:
|
||||
row_to_select = i
|
||||
self.project_combo.setCurrentIndex(row_to_select)
|
||||
self.project_combo.blockSignals(False)
|
||||
|
||||
def set_project(self, project_id: str) -> None:
|
||||
"""Khoá phạm vi quét vào một project (chuỗi rỗng là bỏ khoá)."""
|
||||
pid = project_id or ""
|
||||
self._refresh_project_combo()
|
||||
target = self.project_combo.findData(pid)
|
||||
if target < 0:
|
||||
target = 0
|
||||
if self.project_combo.currentIndex() == target:
|
||||
self._on_project_changed(target)
|
||||
else:
|
||||
self.project_combo.setCurrentIndex(target)
|
||||
|
||||
def _on_project_changed(self, _idx: int) -> None:
|
||||
"""Áp trạng thái khoá: đường dẫn chuyển sang chỉ đọc và trỏ vào thư mục"""
|
||||
from cowork_local.core.projects import load_project
|
||||
|
||||
pid = self.project_combo.currentData() or ""
|
||||
project_changed = pid != self._active_project_id
|
||||
self._active_project_id = pid
|
||||
locked = bool(pid)
|
||||
self.path_edit.setReadOnly(locked)
|
||||
self._pick_btn.setEnabled(not locked)
|
||||
if locked:
|
||||
project = load_project(pid)
|
||||
if project is not None:
|
||||
self.path_edit.setText(str(project.workspace_dir()))
|
||||
# Changing the FOLDER changes what we scan just as much as changing the
|
||||
# project does. Keying this off the id alone left the path in the bar
|
||||
# updated while the graph in the middle still showed the old folder's
|
||||
# nodes: "Đổi" in the Project screen moves the folder, never the id.
|
||||
duong_dan = self.path_edit.text().strip()
|
||||
doi_muc_tieu = project_changed or duong_dan != self._active_path
|
||||
self._active_path = duong_dan
|
||||
if doi_muc_tieu:
|
||||
self.project_changed.emit() # GraphQaWidget drops its temp extraction cache
|
||||
# Mark it and scan on the next visit rather than now — see
|
||||
# auto_scan_and_fit()'s docstring for why.
|
||||
self._needs_scan = True
|
||||
# ...except when this screen is the one on show. The picker lives HERE,
|
||||
# so a user changing project is already looking at the graph: there is
|
||||
# no "next visit" to defer to, and they had to press Scan by hand.
|
||||
# Deferring still applies when the change came from the Workspace
|
||||
# screen while this one is hidden, which is what it was for.
|
||||
if self.isVisible() and duong_dan:
|
||||
self._needs_scan = False
|
||||
self._scan()
|
||||
@@ -27,6 +27,7 @@ from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.graph import graph_export
|
||||
from cowork_local.presentation.graph.graph_messages_view import GraphMessagesView
|
||||
from cowork_local.presentation.graph.graph_project_lock import GraphProjectLockMixin
|
||||
from cowork_local.presentation.graph.graph_scene_builder import build_scene
|
||||
from cowork_local.presentation.graph.graph_scene_items import _Bridge, _Edge, _GraphView, _Node
|
||||
from cowork_local.presentation.shared import HAS_WEB_ENGINE
|
||||
@@ -35,7 +36,7 @@ from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
class GraphRenderer(QWidget):
|
||||
class GraphRenderer(GraphProjectLockMixin, QWidget):
|
||||
"""Nửa "đồ thị" của màn GraphRAG: thanh công cụ, khung xem và vòng đời quét."""
|
||||
status_message = Signal(str)
|
||||
node_selected = Signal(object) # a node's .data, whenever the scene selection changes
|
||||
@@ -60,6 +61,8 @@ class GraphRenderer(QWidget):
|
||||
self._needs_scan = False
|
||||
self._scan_seq = 0 # only the latest scan's result is rendered (no stale overwrite)
|
||||
self._active_project_id = "" # "" = free path; set = scan locked to that project's sandbox
|
||||
# The folder last scanned — see graph_project_lock.py.
|
||||
self._active_path = ""
|
||||
|
||||
self._rescan_timer = QTimer(self)
|
||||
self._rescan_timer.setSingleShot(True)
|
||||
@@ -154,63 +157,6 @@ class GraphRenderer(QWidget):
|
||||
"""
|
||||
return [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)]
|
||||
|
||||
# ---- project sandbox lock ------------------------------------------------- #
|
||||
def _refresh_project_combo(self) -> None:
|
||||
"""Nạp lại danh sách project vào bộ chọn, giữ nguyên project đang chọn."""
|
||||
from cowork_local.core.projects import list_projects
|
||||
|
||||
keep = self._active_project_id
|
||||
self.project_combo.blockSignals(True)
|
||||
self.project_combo.clear()
|
||||
self.project_combo.addItem(tr("structure.project_none"), "")
|
||||
row_to_select = 0
|
||||
for i, p in enumerate(list_projects(), start=1):
|
||||
self.project_combo.addItem(p.name, p.project_id)
|
||||
if p.project_id == keep:
|
||||
row_to_select = i
|
||||
self.project_combo.setCurrentIndex(row_to_select)
|
||||
self.project_combo.blockSignals(False)
|
||||
|
||||
def set_project(self, project_id: str) -> None:
|
||||
"""Khoá phạm vi quét vào một project (chuỗi rỗng là bỏ khoá)."""
|
||||
pid = project_id or ""
|
||||
self._refresh_project_combo()
|
||||
target = self.project_combo.findData(pid)
|
||||
if target < 0:
|
||||
target = 0
|
||||
if self.project_combo.currentIndex() == target:
|
||||
self._on_project_changed(target)
|
||||
else:
|
||||
self.project_combo.setCurrentIndex(target)
|
||||
|
||||
def _on_project_changed(self, _idx: int) -> None:
|
||||
"""Áp trạng thái khoá: đường dẫn chuyển sang chỉ đọc và trỏ vào thư mục"""
|
||||
from cowork_local.core.projects import load_project
|
||||
|
||||
pid = self.project_combo.currentData() or ""
|
||||
project_changed = pid != self._active_project_id
|
||||
self._active_project_id = pid
|
||||
locked = bool(pid)
|
||||
self.path_edit.setReadOnly(locked)
|
||||
self._pick_btn.setEnabled(not locked)
|
||||
if locked:
|
||||
project = load_project(pid)
|
||||
if project is not None:
|
||||
self.path_edit.setText(str(project.workspace_dir()))
|
||||
if project_changed:
|
||||
self.project_changed.emit() # GraphQaWidget drops its temp extraction cache
|
||||
# Mark it and scan on the next visit rather than now — see
|
||||
# auto_scan_and_fit()'s docstring for why.
|
||||
self._needs_scan = True
|
||||
# ...except when this screen is the one on show. The picker lives HERE,
|
||||
# so a user changing project is already looking at the graph: there is
|
||||
# no "next visit" to defer to, and they had to press Scan by hand.
|
||||
# Deferring still applies when the change came from the Workspace
|
||||
# screen while this one is hidden, which is what it was for.
|
||||
if self.isVisible() and self.path_edit.text().strip():
|
||||
self._needs_scan = False
|
||||
self._scan()
|
||||
|
||||
# ---- helpers ---------------------------------------------------------------- #
|
||||
def _pick(self) -> None:
|
||||
"""Mở hộp thoại chọn thư mục gốc để quét."""
|
||||
@@ -237,7 +183,7 @@ class GraphRenderer(QWidget):
|
||||
# ---- prewarm / scan lifecycle -------------------------------------------------- #
|
||||
def prewarm(self) -> None:
|
||||
"""Pay for the graph view before it is clicked on, not during."""
|
||||
if not HAS_WEB_ENGINE or self.web is not None:
|
||||
if self.web is not None:
|
||||
return
|
||||
self._ensure_web()
|
||||
if self._graph is None and self.path_edit.text().strip():
|
||||
|
||||
@@ -40,6 +40,9 @@ class StructureGraphView(QWidget):
|
||||
# Project ma man Workspace da ap xuong lan gan nhat. None = chua ap lan
|
||||
# nao, de lan goi dau tien khong bi bo qua ke ca khi pid la chuoi rong.
|
||||
self._workspace_project = None
|
||||
# Thư mục của project đó lúc áp gần nhất. Chốt theo CẢ đường dẫn chứ
|
||||
# không chỉ theo id — xem set_workspace_project.
|
||||
self._workspace_dir = None
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
self.renderer = GraphRenderer(ctx)
|
||||
@@ -208,13 +211,29 @@ class StructureGraphView(QWidget):
|
||||
|
||||
Đổi sang project khác ở màn Workspace thì vẫn áp — cùng luật với tab Thư
|
||||
mục (``FolderTab.set_project_root``). Chỉ lần refresh trong CÙNG một
|
||||
project là không được đụng.
|
||||
project VÀ cùng một thư mục là không được đụng.
|
||||
|
||||
Chốt theo cả đường dẫn chứ không chỉ theo id: đổi thư mục làm việc ở
|
||||
màn Project không làm id đổi, nên chốt theo mỗi id thì màn này giữ
|
||||
nguyên đường dẫn cũ và quét nhầm thư mục. Tab Thư mục vốn đã chốt theo
|
||||
đường dẫn — đây là đưa hai nơi về đúng cùng một luật như comment này
|
||||
vẫn nói.
|
||||
"""
|
||||
if project_id == self._workspace_project:
|
||||
thu_muc = self._thu_muc_cua(project_id)
|
||||
if project_id == self._workspace_project and thu_muc == self._workspace_dir:
|
||||
return
|
||||
self._workspace_project = project_id
|
||||
self._workspace_dir = thu_muc
|
||||
self.set_project(project_id)
|
||||
|
||||
@staticmethod
|
||||
def _thu_muc_cua(project_id: str) -> str:
|
||||
"""Thư mục làm việc hiện tại của project, chuỗi rỗng nếu không có."""
|
||||
from cowork_local.core.projects import load_project
|
||||
|
||||
project = load_project(project_id) if project_id else None
|
||||
return str(project.workspace_dir()) if project is not None else ""
|
||||
|
||||
def prewarm(self) -> None:
|
||||
"""Dựng sẵn khung đồ thị trước khi người dùng bấm vào, để lần mở đầu không giật."""
|
||||
self.renderer.prewarm()
|
||||
|
||||
@@ -10,6 +10,7 @@ the ``status_message`` signal, ``select_subtab(index)``, ``nav_subtabs()``,
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from typing import List
|
||||
|
||||
from PySide6.QtCore import QTimer, Signal
|
||||
@@ -18,6 +19,7 @@ from PySide6.QtWidgets import QHBoxLayout, QLabel, QTabWidget, QVBoxLayout, QWid
|
||||
from ...core import audit_log
|
||||
from ...i18n import on_language_changed, tr
|
||||
from ...state import AppContext
|
||||
from ...performance import span
|
||||
from .tabs.action_logs_tab import ActionLogsTab
|
||||
from .tabs.agent_status_tab import AgentStatusTab
|
||||
from .tabs.mcp_tab import McpTab
|
||||
@@ -25,11 +27,25 @@ from .tabs.overview_tab import OverviewTab
|
||||
from .tabs.security_events_tab import SecurityEventsTab
|
||||
|
||||
_REFRESH_MS = 3000
|
||||
# Comfortably larger than any realistic audit-log size — the event tables
|
||||
# have never had pagination controls, so every tab still shows "all matching
|
||||
# events" exactly like before; MonitoringQueryService's pagination support
|
||||
# is exercised for real here, just not surfaced as UI (yet).
|
||||
# Comfortably larger than any realistic audit-log size for the WINDOW of
|
||||
# events _load_events() now actually reads (see _LOG_WINDOW_DAYS below) — this
|
||||
# is MonitoringQueryService's query-side page size, kept unbounded so it
|
||||
# always returns every matching event within the window; the user-facing
|
||||
# "Số dòng/trang" control (DF-006 — see shared/event_table.py::set_page_size,
|
||||
# shared/filter_scaffold.py::build_filter_scaffold's with_page_size) trims
|
||||
# that down for DISPLAY, client-side, per event tab.
|
||||
_UNBOUNDED_PAGE_SIZE = 100_000
|
||||
# _load_events() re-reads the audit log from disk every _REFRESH_MS (3s) via
|
||||
# _auto_refresh(), and audit_log.load_events()/load_shared_audit_events() are
|
||||
# day-sharded JSONL — unbounded start/end means EVERY day file ever written
|
||||
# gets re-read and re-parsed on EVERY tick, which is what actually made
|
||||
# Monitoring "gây nặng khi log lớn" (see DF-006): the slowness was never in
|
||||
# rendering (EventTable paginates client-side, 5-100 rows/page — see
|
||||
# shared/event_table.py::_DEFAULT_PAGE_SIZE), it was this repeated full-history
|
||||
# read.
|
||||
# 30 days is a live-monitoring window, not a hard retention limit — nothing
|
||||
# is deleted, older days are simply not re-read on every 3s tick.
|
||||
_LOG_WINDOW_DAYS = 30
|
||||
|
||||
|
||||
class MonitoringTab(QWidget):
|
||||
@@ -259,14 +275,21 @@ class MonitoringTab(QWidget):
|
||||
|
||||
Có cấu hình thư mục chia sẻ VÀ đọc ra được dữ liệu thì dùng nó, để cả đội
|
||||
nhìn chung một bức tranh; rỗng thì rơi về nhật ký của máy này.
|
||||
|
||||
Chỉ đọc ``_LOG_WINDOW_DAYS`` ngày gần nhất — cả hai nguồn đều lưu theo
|
||||
file JSONL từng ngày, nên bounding ở đây tránh việc đọc lại TOÀN BỘ
|
||||
lịch sử mỗi 3 giây (xem ``_auto_refresh``), là nguyên nhân thật của
|
||||
DF-006 (gây nặng khi log lớn).
|
||||
"""
|
||||
start = date.today() - timedelta(days=_LOG_WINDOW_DAYS)
|
||||
shared_dir = self.ctx.config.shared_dir
|
||||
if shared_dir:
|
||||
from ...core import telemetry_shared
|
||||
shared_events = telemetry_shared.load_shared_audit_events(shared_dir)
|
||||
if shared_events:
|
||||
return shared_events
|
||||
return audit_log.load_events()
|
||||
with span("monitoring.load_events", window_days=_LOG_WINDOW_DAYS):
|
||||
if shared_dir:
|
||||
from ...core import telemetry_shared
|
||||
shared_events = telemetry_shared.load_shared_audit_events(shared_dir, start=start)
|
||||
if shared_events:
|
||||
return shared_events
|
||||
return audit_log.load_events(start=start)
|
||||
|
||||
def _apply_events_to_event_tabs(self, events: List[dict]) -> None:
|
||||
"""Filters the ALREADY-LOADED event list (see ``_load_events`` — one
|
||||
|
||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt
|
||||
from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt, Signal
|
||||
from PySide6.QtGui import QBrush, QColor
|
||||
from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget
|
||||
|
||||
@@ -17,7 +17,8 @@ from ....ui.icons import DOT_GREEN, DOT_RED, icon
|
||||
from .badges import action_label
|
||||
from .formatters import agent_avatar_icon, fmt_event_time
|
||||
|
||||
_MAX_ROWS = 300
|
||||
_DEFAULT_PAGE_SIZE = 20
|
||||
PAGE_SIZE_OPTIONS = (5, 10, 20, 50, 100)
|
||||
|
||||
|
||||
class _TimeItem(QTableWidgetItem):
|
||||
@@ -62,6 +63,12 @@ class EventTable(QTableWidget):
|
||||
"secret_in_output": "warning",
|
||||
}
|
||||
|
||||
# Emitted whenever the rendered page changes (new data, page-size change,
|
||||
# or prev/next navigation) — args are (current_page, page_count), both
|
||||
# 1-based-friendly in that current_page is 0-indexed but page_count is a
|
||||
# plain count. filter_scaffold.py's pager label/buttons listen to this.
|
||||
page_changed = Signal(int, int)
|
||||
|
||||
def __init__(self, show_result: bool = True):
|
||||
# Security Events drops the result column entirely (see _ACTION_TINTS).
|
||||
"""Bảng sự kiện dùng chung của các tab Giám sát.
|
||||
@@ -70,6 +77,10 @@ class EventTable(QTableWidget):
|
||||
là thất bại nên cột ấy chỉ tốn chỗ.
|
||||
"""
|
||||
self._show_result = show_result
|
||||
self._page_size = _DEFAULT_PAGE_SIZE
|
||||
self._current_page = 0
|
||||
self._last_events: List[dict] = []
|
||||
self._sorted_events: List[dict] = []
|
||||
super().__init__(0, 7 if show_result else 6)
|
||||
self.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
self.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
@@ -98,13 +109,60 @@ class EventTable(QTableWidget):
|
||||
cols += [tr("monitoring.col_detail_block") if not self._show_result else tr("monitoring.col_detail")]
|
||||
self.setHorizontalHeaderLabels(cols)
|
||||
|
||||
def page_size(self) -> int:
|
||||
"""Số dòng đang hiển thị mỗi trang."""
|
||||
return self._page_size
|
||||
|
||||
def page_count(self) -> int:
|
||||
"""Tổng số trang với dữ liệu và số dòng/trang hiện tại (tối thiểu 1)."""
|
||||
if not self._sorted_events:
|
||||
return 1
|
||||
return -(-len(self._sorted_events) // self._page_size) # ceil div
|
||||
|
||||
def current_page(self) -> int:
|
||||
"""Trang đang hiển thị, đánh số từ 0."""
|
||||
return self._current_page
|
||||
|
||||
def go_to_page(self, page: int) -> None:
|
||||
"""Nhảy tới một trang cụ thể (đánh số từ 0), tự kẹp trong khoảng hợp lệ."""
|
||||
self._current_page = page
|
||||
self._render_current_page()
|
||||
|
||||
def next_page(self) -> None:
|
||||
"""Sang trang kế — không làm gì nếu đã ở trang cuối."""
|
||||
self.go_to_page(self._current_page + 1)
|
||||
|
||||
def prev_page(self) -> None:
|
||||
"""Về trang trước — không làm gì nếu đã ở trang đầu."""
|
||||
self.go_to_page(self._current_page - 1)
|
||||
|
||||
def set_page_size(self, n: int) -> None:
|
||||
"""Đổi số dòng hiển thị mỗi trang, quay về trang đầu, rồi vẽ lại với dữ
|
||||
liệu đã có sẵn (không cần refresh lại từ nguồn)."""
|
||||
self._page_size = n
|
||||
self._current_page = 0
|
||||
self._render_current_page()
|
||||
|
||||
def set_events(self, events: List[dict]) -> None:
|
||||
"""Đổ danh sách sự kiện vào bảng: mới nhất lên đầu, cắt ở ``_MAX_ROWS``.
|
||||
"""Đổ danh sách sự kiện vào bảng: mới nhất lên đầu, chia trang theo
|
||||
``self._page_size`` — xem qua trang khác bằng ``next_page``/``prev_page``
|
||||
(nút tiến/lùi ở filter_scaffold.py), không còn bị cắt bỏ vĩnh viễn như
|
||||
trước (DF-006)."""
|
||||
self._last_events = events
|
||||
self._sorted_events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)
|
||||
self._current_page = 0
|
||||
self._render_current_page()
|
||||
|
||||
def _render_current_page(self) -> None:
|
||||
"""Vẽ đúng một trang (theo ``self._current_page``/``self._page_size``)
|
||||
từ ``self._sorted_events`` đã sắp sẵn.
|
||||
|
||||
Tắt sắp xếp trong lúc đổ dữ liệu — để bật, Qt sắp lại sau mỗi dòng và việc
|
||||
nạp chậm đi theo bậc hai.
|
||||
"""
|
||||
events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)[:_MAX_ROWS]
|
||||
self._current_page = max(0, min(self._current_page, self.page_count() - 1))
|
||||
start = self._current_page * self._page_size
|
||||
events = self._sorted_events[start:start + self._page_size]
|
||||
self.setSortingEnabled(False)
|
||||
self.setRowCount(len(events))
|
||||
for row, ev in enumerate(events):
|
||||
@@ -149,6 +207,7 @@ class EventTable(QTableWidget):
|
||||
self.setItem(row, col, item)
|
||||
self.setSortingEnabled(True)
|
||||
self.apply_filter(getattr(self, "_filter_needle", ""))
|
||||
self.page_changed.emit(self._current_page, self.page_count())
|
||||
|
||||
def apply_filter(self, needle: str) -> None:
|
||||
"""Ẩn/hiện dòng theo từ khoá tìm kiếm (không phân biệt hoa thường)."""
|
||||
|
||||
@@ -16,13 +16,13 @@ from typing import Callable, Dict, Optional
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QKeySequence, QShortcut
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication, QHBoxLayout, QLabel, QLineEdit, QPushButton, QSplitter,
|
||||
QTableWidget, QVBoxLayout, QWidget,
|
||||
QApplication, QComboBox, QHBoxLayout, QLabel, QLineEdit, QPushButton,
|
||||
QSplitter, QTableWidget, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ....i18n import bind_tip, tr
|
||||
from ....ui.icons import icon
|
||||
from .event_table import ClickOutsideCloser, EventTable
|
||||
from .event_table import PAGE_SIZE_OPTIONS, ClickOutsideCloser, EventTable
|
||||
from .event_detail_panel import EventDetailPanel
|
||||
|
||||
|
||||
@@ -47,11 +47,12 @@ def _sync_event_detail(table: EventTable, panel: EventDetailPanel) -> None:
|
||||
def build_filter_scaffold(
|
||||
page: QWidget, table: QTableWidget, *, on_refresh: Callable[[], None],
|
||||
title_key: Optional[str] = None, with_search: bool = True,
|
||||
with_detail: bool = False,
|
||||
with_detail: bool = False, with_page_size: bool = False,
|
||||
on_ai_filter: Optional[Callable[[QLineEdit, QPushButton], None]] = None,
|
||||
) -> Dict[str, object]:
|
||||
"""Dựng khung chung cho một tab sự kiện: tiêu đề, nút làm mới, ô tìm kiếm,
|
||||
nút lọc bằng AI và panel chi tiết.
|
||||
nút lọc bằng AI, control "Số dòng/trang" (nếu ``with_page_size``) và panel
|
||||
chi tiết.
|
||||
|
||||
Bốn tab sự kiện của màn Giám sát chỉ khác nhau ở nguồn dữ liệu, nên phần vỏ
|
||||
này được dựng một lần và dùng chung.
|
||||
@@ -91,6 +92,55 @@ def build_filter_scaffold(
|
||||
ai_btn.clicked.connect(lambda: on_ai_filter(search, ai_btn))
|
||||
row.addWidget(search, 1)
|
||||
row.addWidget(ai_btn)
|
||||
if with_page_size and isinstance(table, EventTable):
|
||||
# DF-006: the item-per-page count was never surfaced anywhere in
|
||||
# the UI (design called for it) — EventTable already trims to a
|
||||
# page size internally, this just makes that number visible AND
|
||||
# user-choosable instead of a fixed constant.
|
||||
page_size_lbl = QLabel(tr("monitoring.page_size_label"))
|
||||
page_size_combo = QComboBox()
|
||||
for n in PAGE_SIZE_OPTIONS:
|
||||
page_size_combo.addItem(str(n), n)
|
||||
current = table.page_size()
|
||||
page_size_combo.setCurrentIndex(
|
||||
PAGE_SIZE_OPTIONS.index(current) if current in PAGE_SIZE_OPTIONS else 0)
|
||||
page_size_combo.currentIndexChanged.connect(
|
||||
lambda i: table.set_page_size(page_size_combo.itemData(i)))
|
||||
row.addWidget(page_size_lbl)
|
||||
row.addWidget(page_size_combo)
|
||||
|
||||
# DF-006 follow-up: trimming to a page size alone silently dropped
|
||||
# every row past it with no way back to see them — prev/next
|
||||
# buttons plus a "trang X/Y" indicator make the rest reachable.
|
||||
page_prev_btn = QPushButton()
|
||||
page_prev_btn.setIcon(icon("chevron-left"))
|
||||
page_prev_btn.setCursor(Qt.PointingHandCursor)
|
||||
bind_tip(page_prev_btn, "monitoring.page_prev")
|
||||
page_next_btn = QPushButton()
|
||||
page_next_btn.setIcon(icon("chevron-right"))
|
||||
page_next_btn.setCursor(Qt.PointingHandCursor)
|
||||
bind_tip(page_next_btn, "monitoring.page_next")
|
||||
page_indicator_lbl = QLabel()
|
||||
|
||||
def _refresh_pager(cur: int = None, total: int = None) -> None:
|
||||
if cur is None or total is None:
|
||||
cur, total = table.current_page(), table.page_count()
|
||||
page_indicator_lbl.setText(tr("monitoring.page_indicator", page=cur + 1, total=total))
|
||||
page_prev_btn.setEnabled(cur > 0)
|
||||
page_next_btn.setEnabled(cur < total - 1)
|
||||
|
||||
page_prev_btn.clicked.connect(table.prev_page)
|
||||
page_next_btn.clicked.connect(table.next_page)
|
||||
table.page_changed.connect(_refresh_pager)
|
||||
_refresh_pager()
|
||||
|
||||
row.addWidget(page_prev_btn)
|
||||
row.addWidget(page_indicator_lbl)
|
||||
row.addWidget(page_next_btn)
|
||||
parts.update(
|
||||
page_size_label=page_size_lbl, page_size_combo=page_size_combo,
|
||||
page_prev_btn=page_prev_btn, page_next_btn=page_next_btn,
|
||||
page_indicator_label=page_indicator_lbl, page_pager_refresh=_refresh_pager)
|
||||
lay.addLayout(row)
|
||||
parts.update(filter_edit=search, ai_filter_btn=ai_btn)
|
||||
|
||||
|
||||
@@ -25,13 +25,16 @@ class ActionLogsTab(QWidget):
|
||||
parts = build_filter_scaffold(
|
||||
self, self.table, on_refresh=on_refresh_all,
|
||||
title_key="monitoring.action_logs_title",
|
||||
with_search=True, with_detail=True, on_ai_filter=self._start_ai_filter)
|
||||
with_search=True, with_detail=True, with_page_size=True,
|
||||
on_ai_filter=self._start_ai_filter)
|
||||
self.title_lbl = parts["title_lbl"]
|
||||
self.title_key = parts["title_key"]
|
||||
self.title_refresh_btn = parts["title_refresh_btn"]
|
||||
self.filter_edit = parts["filter_edit"]
|
||||
self.ai_filter_btn = parts["ai_filter_btn"]
|
||||
self.detail_panel = parts["detail_panel"]
|
||||
self.page_size_label = parts["page_size_label"]
|
||||
self.page_pager_refresh = parts["page_pager_refresh"]
|
||||
|
||||
def set_events(self, events: List[dict]) -> None:
|
||||
"""Đổ danh sách sự kiện vào bảng."""
|
||||
@@ -44,6 +47,8 @@ class ActionLogsTab(QWidget):
|
||||
self.detail_panel.retranslate()
|
||||
self.title_lbl.setText(tr(self.title_key))
|
||||
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
||||
self.page_size_label.setText(tr("monitoring.page_size_label"))
|
||||
self.page_pager_refresh()
|
||||
|
||||
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
|
||||
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
|
||||
|
||||
@@ -20,6 +20,7 @@ from ....core import admin_agents, preview_ai
|
||||
from ....core.worker import AgentWorker
|
||||
from ....i18n import on_language_changed, tr
|
||||
from ....state import AppContext
|
||||
from ....ui.dialog_buttons import dialog_buttons
|
||||
from ....ui.icons import icon
|
||||
from ....ui.widgets import ToggleSwitch, badge_pill_widget
|
||||
|
||||
@@ -95,7 +96,7 @@ class AgentEditDialog(QDialog):
|
||||
self.enabled_chk = QCheckBox(tr("agents_admin.f_enabled"))
|
||||
self.enabled_chk.setChecked(agent.enabled if agent else True)
|
||||
form.addRow("", self.enabled_chk)
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
||||
buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
form.addRow(buttons)
|
||||
|
||||
@@ -26,7 +26,7 @@ from typing import Dict, List
|
||||
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout, QHeaderView, QLabel, QMessageBox, QPushButton,
|
||||
QHBoxLayout, QHeaderView, QLabel, QPushButton,
|
||||
QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
@@ -35,6 +35,7 @@ from ....core import admin_agents
|
||||
from ....core.worker import AgentWorker
|
||||
from ....i18n import on_language_changed, tr
|
||||
from ....state import AppContext
|
||||
from ....ui.dialog_buttons import confirm
|
||||
from ....ui.icons import icon
|
||||
from ....ui.widgets import ToggleSwitch, badge_pill_widget
|
||||
from .agent_edit_dialog import AgentEditDialog
|
||||
@@ -189,9 +190,8 @@ class AgentsAdminTab(QWidget):
|
||||
agent = admin_agents.load_agent(agent_id, self._dir())
|
||||
if agent is None:
|
||||
return
|
||||
if QMessageBox.question(
|
||||
self, tr("agents_admin.delete_title"),
|
||||
tr("agents_admin.delete_confirm", name=agent.name)) != QMessageBox.Yes:
|
||||
if not confirm(self, tr("agents_admin.delete_title"),
|
||||
tr("agents_admin.delete_confirm", name=agent.name)):
|
||||
return
|
||||
admin_agents.delete_agent(agent.agent_id, self._dir())
|
||||
self.refresh()
|
||||
|
||||
@@ -25,13 +25,16 @@ class McpTab(QWidget):
|
||||
parts = build_filter_scaffold(
|
||||
self, self.table, on_refresh=on_refresh_all,
|
||||
title_key="monitoring.mcp_history_title",
|
||||
with_search=True, with_detail=True, on_ai_filter=self._start_ai_filter)
|
||||
with_search=True, with_detail=True, with_page_size=True,
|
||||
on_ai_filter=self._start_ai_filter)
|
||||
self.title_lbl = parts["title_lbl"]
|
||||
self.title_key = parts["title_key"]
|
||||
self.title_refresh_btn = parts["title_refresh_btn"]
|
||||
self.filter_edit = parts["filter_edit"]
|
||||
self.ai_filter_btn = parts["ai_filter_btn"]
|
||||
self.detail_panel = parts["detail_panel"]
|
||||
self.page_size_label = parts["page_size_label"]
|
||||
self.page_pager_refresh = parts["page_pager_refresh"]
|
||||
|
||||
def set_events(self, events: List[dict]) -> None:
|
||||
"""Đổ danh sách sự kiện vào bảng."""
|
||||
@@ -44,6 +47,8 @@ class McpTab(QWidget):
|
||||
self.detail_panel.retranslate()
|
||||
self.title_lbl.setText(tr(self.title_key))
|
||||
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
||||
self.page_size_label.setText(tr("monitoring.page_size_label"))
|
||||
self.page_pager_refresh()
|
||||
|
||||
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
|
||||
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
|
||||
|
||||
@@ -139,11 +139,11 @@ class PricingPanel(QGroupBox):
|
||||
|
||||
def _add_pricing_row(self) -> None:
|
||||
"""Thêm một dòng đơn giá trống để người dùng điền tay."""
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
from ....ui.dialog_buttons import ask_text
|
||||
|
||||
from ....core import model_pricing as mp
|
||||
name, ok = QInputDialog.getText(self, tr("monitoring.pricing_add"),
|
||||
tr("monitoring.pricing_add_prompt"))
|
||||
name, ok = ask_text(self, tr("monitoring.pricing_add"),
|
||||
tr("monitoring.pricing_add_prompt"))
|
||||
name = (name or "").strip()
|
||||
if not ok or not name:
|
||||
return
|
||||
|
||||
@@ -31,13 +31,16 @@ class SecurityEventsTab(QWidget):
|
||||
parts = build_filter_scaffold(
|
||||
self, self.table, on_refresh=on_refresh_all,
|
||||
title_key="monitoring.security_events_title",
|
||||
with_search=True, with_detail=True, on_ai_filter=self._start_ai_filter)
|
||||
with_search=True, with_detail=True, with_page_size=True,
|
||||
on_ai_filter=self._start_ai_filter)
|
||||
self.title_lbl = parts["title_lbl"]
|
||||
self.title_key = parts["title_key"]
|
||||
self.title_refresh_btn = parts["title_refresh_btn"]
|
||||
self.filter_edit = parts["filter_edit"]
|
||||
self.ai_filter_btn = parts["ai_filter_btn"]
|
||||
self.detail_panel = parts["detail_panel"]
|
||||
self.page_size_label = parts["page_size_label"]
|
||||
self.page_pager_refresh = parts["page_pager_refresh"]
|
||||
|
||||
def set_events(self, events: List[dict]) -> None:
|
||||
"""Đổ danh sách sự kiện vào bảng."""
|
||||
@@ -50,6 +53,8 @@ class SecurityEventsTab(QWidget):
|
||||
self.detail_panel.retranslate()
|
||||
self.title_lbl.setText(tr(self.title_key))
|
||||
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
||||
self.page_size_label.setText(tr("monitoring.page_size_label"))
|
||||
self.page_pager_refresh()
|
||||
|
||||
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
|
||||
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
|
||||
|
||||
@@ -179,9 +179,12 @@ class ToolsAdminTab(QWidget):
|
||||
hdr.addWidget(sw)
|
||||
lay.addLayout(hdr)
|
||||
|
||||
desc = QLabel(spec.description)
|
||||
# spec.description là mô tả gửi cho mô hình (schema function-calling),
|
||||
# luôn tiếng Anh và viết cho máy đọc — thẻ này dùng bản dịch riêng.
|
||||
desc_text = tr(f"tools_admin.desc.{spec.name}")
|
||||
desc = QLabel(desc_text)
|
||||
desc.setWordWrap(True)
|
||||
desc.setToolTip(spec.description)
|
||||
desc.setToolTip(desc_text)
|
||||
desc.setObjectName("hint")
|
||||
desc.setStyleSheet("border: none;")
|
||||
lay.addWidget(desc)
|
||||
@@ -213,7 +216,8 @@ class ToolsAdminTab(QWidget):
|
||||
result. Respects the fetch_url toggle: when web access is OFF the agent
|
||||
cannot reach the internet, so the test reports that instead of probing."""
|
||||
disabled = ("fetch_url" in self.ctx.config.tools_disabled
|
||||
or not bool(self.ctx.config.agent_security.get("allow_url_fetch", True)))
|
||||
or not bool(self.ctx.config.agent_security.get("allow_url_fetch", True))
|
||||
or bool(self.ctx.config.agent_security.get("block_network", False)))
|
||||
if disabled:
|
||||
self.test_internet_status.setText(tr("tools_admin.internet_disabled"))
|
||||
self.test_internet_status.setStyleSheet("color: #c00;")
|
||||
|
||||
@@ -32,6 +32,7 @@ from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.presentation.scheduling.ai_task_import_dialog import ImportTaskPanel
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.ui.dialog_buttons import dialog_buttons
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
@@ -79,8 +80,8 @@ class AiTaskCreatorDialog(QDialog):
|
||||
self.import_panel.tasks_changed.connect(self._on_import_tasks_changed)
|
||||
self.tabs.addTab(self.import_panel, tr("schedtask.tab_import"))
|
||||
|
||||
self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
self.buttons.button(QDialogButtonBox.Ok).setText(tr("schedtask.ai_confirm"))
|
||||
self.buttons = dialog_buttons(QDialogButtonBox.Ok | QDialogButtonBox.Cancel,
|
||||
ok="schedtask.ai_confirm")
|
||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(False)
|
||||
self.buttons.accepted.connect(self._confirm)
|
||||
self.buttons.rejected.connect(self.reject)
|
||||
|
||||
@@ -38,6 +38,7 @@ from cowork_local.infrastructure.persistence.json.task_repository_impl import (
|
||||
)
|
||||
from cowork_local.presentation.scheduling.run_history_dialog import RunHistoryDialog
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.dialog_buttons import confirm
|
||||
from cowork_local.ui.osutil import open_path
|
||||
|
||||
# Priority shown as a plain text tag (no colored-emoji squares). Only the
|
||||
@@ -317,9 +318,8 @@ class KanbanBoardWidget(QWidget):
|
||||
elif chosen == next_act:
|
||||
self._create_next_from_output(task)
|
||||
elif chosen == del_act:
|
||||
if QMessageBox.question(self, tr("schedtask.menu_delete"),
|
||||
tr("schedtask.delete_confirm", title=task.get("title", ""))
|
||||
) == QMessageBox.Yes:
|
||||
if confirm(self, tr("schedtask.menu_delete"),
|
||||
tr("schedtask.delete_confirm", title=task.get("title", ""))):
|
||||
self._service.delete(tid)
|
||||
self.refresh()
|
||||
|
||||
@@ -335,9 +335,8 @@ class KanbanBoardWidget(QWidget):
|
||||
"""Confirm, then delete every task in ``selected``. Split out of
|
||||
_bulk_delete_menu so tests can drive it directly without having to
|
||||
fake a real (modal, event-loop-blocking) QMenu popup."""
|
||||
if QMessageBox.question(
|
||||
self, tr("schedtask.menu_delete"),
|
||||
tr("schedtask.delete_multi_confirm", n=len(selected))) != QMessageBox.Yes:
|
||||
if not confirm(self, tr("schedtask.menu_delete"),
|
||||
tr("schedtask.delete_multi_confirm", n=len(selected))):
|
||||
return False
|
||||
ids = [it.data(Qt.UserRole) for it in selected if it.data(Qt.UserRole)]
|
||||
self._service.bulk_delete(ids)
|
||||
@@ -381,7 +380,7 @@ class KanbanBoardWidget(QWidget):
|
||||
nxt["dependency"]["previous_task_id"] = task["task_id"]
|
||||
err = chain_error(self._repo.list() + [nxt], task["task_id"], nxt["task_id"])
|
||||
if err:
|
||||
QMessageBox.warning(self, tr("schedtask.g_dependency"), err)
|
||||
QMessageBox.warning(self, tr("schedtask.g_dependency"), tr(err))
|
||||
return
|
||||
self._repo.save(nxt)
|
||||
task["dependency"]["next_task_id"] = nxt["task_id"]
|
||||
|
||||
@@ -10,6 +10,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.ui.dialog_buttons import dialog_buttons
|
||||
from cowork_local.ui.osutil import open_path
|
||||
|
||||
|
||||
@@ -53,7 +54,7 @@ class RunHistoryDialog(QDialog):
|
||||
self.table.itemDoubleClicked.connect(self._open_artifact)
|
||||
root.addWidget(self.table, 1)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Close)
|
||||
buttons = dialog_buttons(QDialogButtonBox.Close)
|
||||
buttons.rejected.connect(self.reject)
|
||||
buttons.accepted.connect(self.accept)
|
||||
root.addWidget(buttons)
|
||||
|
||||
@@ -43,10 +43,10 @@ class AboutSettingsWidget(QWidget):
|
||||
self.app_label.setFont(font)
|
||||
layout.addWidget(self.app_label)
|
||||
|
||||
self.credit_label = QLabel(tr("app.credit"))
|
||||
self.credit_label.setObjectName("faint")
|
||||
self.credit_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
layout.addWidget(self.credit_label)
|
||||
self.version_label = QLabel(tr("app.version", v=__version__))
|
||||
self.version_label.setObjectName("faint")
|
||||
self.version_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
layout.addWidget(self.version_label)
|
||||
|
||||
layout.addStretch(1)
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Width of a navigation list whose current row is drawn bold.
|
||||
|
||||
The theme draws the selected ``sectionIndex`` row with ``font-weight: 600``
|
||||
and 10px padding each side (``theme/qss.py``). Sizing the list from the plain
|
||||
font left the selected label too wide for its row, so "Sandbox Security
|
||||
Layer" was cut off in Settings as soon as it was picked.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from PySide6.QtGui import QFont, QFontMetrics
|
||||
|
||||
#: Item padding (10px x 2) + item radius + list frame, with a little slack.
|
||||
ROW_CHROME_PX = 48
|
||||
|
||||
|
||||
def selected_label_width(widget, labels: Iterable[str]) -> int:
|
||||
"""Pixels needed to show the widest of ``labels`` in the bold selected style."""
|
||||
widget.ensurePolished()
|
||||
bold = QFont(widget.font())
|
||||
bold.setWeight(QFont.Weight.DemiBold)
|
||||
metrics = QFontMetrics(bold)
|
||||
return max((metrics.horizontalAdvance(label) for label in labels), default=0) + ROW_CHROME_PX
|
||||
|
||||
|
||||
__all__ = ["ROW_CHROME_PX", "selected_label_width"]
|
||||
@@ -27,7 +27,14 @@ def _frozen_onefile() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# QtWebEngine is noisy and unreliable on the macOS runtime we support (GPU/
|
||||
# helper-process failures leave the stacked view blank). The native Qt graph is
|
||||
# already available and avoids that failure path entirely.
|
||||
HAS_WEB_ENGINE = False
|
||||
|
||||
try: # WebEngine + WebChannel are optional PySide6 add-ons
|
||||
if sys.platform == "darwin":
|
||||
raise ImportError("use native graph renderer on macOS")
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView # noqa: F401
|
||||
from PySide6.QtWebChannel import QWebChannel # noqa: F401
|
||||
HAS_WEB_ENGINE = not _frozen_onefile()
|
||||
|
||||
@@ -42,5 +42,14 @@ def build_config(path: Path | None = None) -> JsonConfigRepository:
|
||||
|
||||
|
||||
def build_context(path: Path | None = None) -> AppContext:
|
||||
"""Dựng AppContext hoàn chỉnh — điểm vào cho ``app.run()`` và cho checker."""
|
||||
return AppContext(build_config(path))
|
||||
"""Dựng AppContext hoàn chỉnh — điểm vào cho ``app.run()`` và cho checker.
|
||||
|
||||
Nối luôn cổng mạng chung vào cấu hình SỐNG của context: đổi công tắc
|
||||
"Chặn mạng" trong Settings là có hiệu lực ngay ở lời gọi mạng kế tiếp.
|
||||
"""
|
||||
from ...application.network import network_guard
|
||||
from ...core.agent_security import sandbox_settings
|
||||
|
||||
ctx = AppContext(build_config(path))
|
||||
network_guard.bind(lambda: sandbox_settings(ctx.config)[1])
|
||||
return ctx
|
||||
|
||||
@@ -32,9 +32,7 @@ from .rail_metrics import _NAV_MIN_WIDTH
|
||||
from .tray_manager import TrayManager
|
||||
from ...state import AppContext
|
||||
from ...core.task_scheduler import TaskScheduler
|
||||
from ...ui.cowork_tab import CoworkTab
|
||||
from ...ui.sidebar import HistorySidebar
|
||||
from ..graph.structure_graph_view import StructureGraphView
|
||||
from ...ui.workspace_tab import WorkspaceTab
|
||||
|
||||
|
||||
@@ -111,10 +109,9 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
|
||||
# Workspace screen (per selected project). GraphRAG's heavy
|
||||
# QtWebEngine is still built lazily on first display
|
||||
# (StructureGraphView._ensure_web).
|
||||
from ...ui.cowork_tab import CoworkTab
|
||||
self.cowork = CoworkTab(ctx)
|
||||
self.structure = StructureGraphView(ctx)
|
||||
self.structure.status_message.connect(self.statusBar().showMessage)
|
||||
self.cowork.output_changed.connect(self.structure.schedule_rescan)
|
||||
self.structure = None
|
||||
self.cowork.status_message.connect(self.statusBar().showMessage)
|
||||
# Refresh History (list + running markers + current highlight) whenever a
|
||||
# conversation is created/updated or a turn finishes.
|
||||
@@ -177,10 +174,10 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
|
||||
self.help_agent.status_message.connect(self.statusBar().showMessage)
|
||||
|
||||
self.statusBar().showMessage(tr("app.status.ready"))
|
||||
# Dòng ghi công tác giả đã chuyển vào Cài đặt ▸ Giới thiệu
|
||||
# (presentation/settings/about_widget.py). Nó từng là widget thường trực
|
||||
# ở góc dưới phải: chiếm một góc màn hình trên MỌI màn hình, suốt cả
|
||||
# phiên, cho một thông tin chỉ cần đọc một lần.
|
||||
# Góc dưới phải: đúng một dòng phiên bản (cùng nguồn với tiêu đề cửa sổ).
|
||||
# Tắt size grip — nó vẽ một vệt ngay bên phải chữ; cửa sổ vẫn kéo được cạnh.
|
||||
self.statusBar().setSizeGripEnabled(False)
|
||||
self.statusBar().addPermanentWidget(QLabel(tr("app.version", v=__version__)))
|
||||
self._restore_sessions()
|
||||
# Open on "All projects…" — literally the same call the nav rail's link
|
||||
# of that name makes, so the rail highlight and the content can never
|
||||
@@ -218,6 +215,11 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
|
||||
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:
|
||||
# The Cowork composer can wrap an extra control row as the window
|
||||
# narrows/widens, which changes how much bottom guard the dock
|
||||
# needs — recompute it on every resize, not just reposition with
|
||||
# whatever guard height was last measured at tab-entry time.
|
||||
self._update_dock_guard()
|
||||
self.help_agent.reposition()
|
||||
|
||||
def showEvent(self, event): # noqa: N802 - Qt override
|
||||
|
||||
@@ -81,7 +81,7 @@ class NavRailMixin:
|
||||
# 16px icon up with the nav items' icons below (1px list frame + item
|
||||
# padding) — same indent level, same icon size as e.g. Dashboard.
|
||||
toggle_row = QHBoxLayout()
|
||||
toggle_row.setContentsMargins(0, 8, 10, 8)
|
||||
toggle_row.setContentsMargins(10, 8, 10, 8)
|
||||
toggle_row.addWidget(self._nav_toggle_btn, 0, Qt.AlignLeft)
|
||||
toggle_row.addStretch(1)
|
||||
nvl.addLayout(toggle_row)
|
||||
@@ -111,7 +111,7 @@ class NavRailMixin:
|
||||
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.setContentsMargins(10, 0, 10, 6)
|
||||
head.setSpacing(6)
|
||||
head.addWidget(self.nav_project)
|
||||
head.addWidget(self.nav_project_btn)
|
||||
@@ -136,7 +136,7 @@ class NavRailMixin:
|
||||
self._nav_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
scroll_body = QWidget()
|
||||
sv = QVBoxLayout(scroll_body)
|
||||
sv.setContentsMargins(0, 0, 0, 0)
|
||||
sv.setContentsMargins(6, 0, 6, 0)
|
||||
sv.setSpacing(0)
|
||||
sv.addWidget(self.nav, 0)
|
||||
# RECENTS — the threads of the project named in the picker above, right
|
||||
@@ -197,9 +197,9 @@ class NavRailMixin:
|
||||
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.
|
||||
Workspace contributes all five of its sub-views; ``_rebuild_nav`` bỏ
|
||||
những hàng mà cổng project đang đóng (Cowork, GraphRAG) thay vì hiện
|
||||
chúng ở dạng mờ.
|
||||
"""
|
||||
rows = [(self.nav, self._ROW_WORKSPACE, sub, label, ic, on)
|
||||
for label, sub, ic, on in self.workspace.nav_entries()]
|
||||
@@ -238,37 +238,28 @@ class NavRailMixin:
|
||||
tree.clear()
|
||||
tree.blockSignals(blocked)
|
||||
for tree, page, sub, label, icon_name, enabled in spec:
|
||||
if not enabled:
|
||||
# Cổng project đóng → bỏ hẳn hàng, không hiện dạng mờ nữa.
|
||||
continue
|
||||
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:
|
||||
if 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.
|
||||
#
|
||||
# The frame, and nothing else. A flat ``+ 8`` here used to leave 6px
|
||||
# of dead space under the last row of each list, and because the
|
||||
# Settings button sits DIRECTLY under nav_bottom (nvl has no
|
||||
# spacing), that space landed between Giám sát and Settings only —
|
||||
# so three rows that read as one list were spaced 18/26px. Padding
|
||||
# a row is the item delegate's job; this is the frame's.
|
||||
# Rows plus frame, nothing else: the flat ``+ 8`` this replaces
|
||||
# left 6px of dead space under the last row, and since Settings
|
||||
# sits directly under nav_bottom it fell between Giám sát and
|
||||
# Settings alone — 18/26px for rows that read as one list.
|
||||
row_h = 0
|
||||
for tree in (self.nav, self.nav_bottom):
|
||||
n = tree.topLevelItemCount()
|
||||
row_h = tree.sizeHintForRow(0) if n else row_h
|
||||
tree.setFixedHeight(n * row_h + 2 * tree.frameWidth())
|
||||
# Settings is one more row of the same list, so it gets the rows'
|
||||
# own height rather than a second set of paddings guessed to match
|
||||
# it — the only way the three stay evenly spaced when the font (and
|
||||
# with it ``sizeHintForRow``) is not the one this was tuned on.
|
||||
# Settings is one more row of the list, so it takes the rows' own
|
||||
# height instead of paddings guessed to match it.
|
||||
if row_h and hasattr(self, "_nav_settings_btn"):
|
||||
self._nav_settings_btn.setFixedHeight(row_h)
|
||||
if keep:
|
||||
|
||||
@@ -142,10 +142,11 @@ class PageRegistryMixin:
|
||||
Vệt sáng trên thanh menu cũng cập nhật ở đây, để nó đi theo NỘI DUNG chứ
|
||||
không theo thứ vừa được bấm.
|
||||
"""
|
||||
was_page = self.pages.currentIndex()
|
||||
self._ensure_page(page) # build lazy page on first visit
|
||||
self.pages.setCurrentIndex(page)
|
||||
if page == self._ROW_WORKSPACE:
|
||||
self.workspace.refresh() # re-list projects + threads on entry
|
||||
if page == self._ROW_WORKSPACE and was_page != page:
|
||||
self.workspace.refresh() # refresh only when entering Workspace
|
||||
widget = self._page_widgets[page]
|
||||
if sub is not None and hasattr(widget, "select_subtab"):
|
||||
# Enforce the project gate here rather than at each entry point. A
|
||||
|
||||
@@ -13,8 +13,14 @@ from PySide6.QtWidgets import QStyledItemDelegate
|
||||
# ---- kích thước ---------------------------------------------------------
|
||||
_NAV_EXPANDED_WIDTH = 232
|
||||
_NAV_COLLAPSED_WIDTH = 54
|
||||
_NAV_ROW_INSET = 4
|
||||
_NAV_ROW_INSET = 8
|
||||
_NAV_ROW_GAP = 6
|
||||
# Khe TRÊN nút Cài đặt, tính bằng khoảng trống thật trong layout của rail.
|
||||
# Không đặt bằng ``margin`` trong QSS: margin của stylesheet được vẽ BÊN TRONG
|
||||
# hộp của widget, mà nút này lại bị ``_rebuild_nav`` ghim đúng chiều cao một
|
||||
# dòng menu — nên margin không mua được một pixel khoảng cách nào.
|
||||
# Settings dùng cùng nhịp hàng với Dashboard và Monitoring.
|
||||
_NAV_SETTINGS_GAP = 0
|
||||
# 132 -> 232: o 132px nhan "Cuoc tro chuyen moi" bi cat mat chu. San phai du
|
||||
# rong cho nhan DAI NHAT tren thanh, khong phai cho nhan trung binh.
|
||||
_NAV_MIN_WIDTH = 232
|
||||
|
||||
@@ -42,6 +42,12 @@ class RailProjectMixin:
|
||||
# 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"), "")
|
||||
elif not current:
|
||||
# Có project nhưng CHƯA chọn cái nào (mở app lên, hoặc vừa xoá
|
||||
# project đang mở). Không có mục này thì combo rơi về mục 0 và
|
||||
# chỉ bừa vào project đầu danh sách, trong khi cổng
|
||||
# Cowork/GraphRAG vẫn đóng — hai chỗ nói hai đằng.
|
||||
self.nav_project.insertItem(0, tr("app.nav.pick_project"), "")
|
||||
idx = self.nav_project.findData(current)
|
||||
if idx >= 0:
|
||||
self.nav_project.setCurrentIndex(idx)
|
||||
|
||||
@@ -42,7 +42,14 @@ class SessionEventsMixin:
|
||||
self.sidebar.refresh()
|
||||
self._refresh_rail_recents() # the rail shortcut follows the panel
|
||||
|
||||
QTimer.singleShot(0, _do)
|
||||
# Coalesce bursts from turn/tool/history signals into one sidebar read.
|
||||
timer = getattr(self, "_history_refresh_timer", None)
|
||||
if timer is None:
|
||||
timer = QTimer(self)
|
||||
timer.setSingleShot(True)
|
||||
timer.timeout.connect(_do)
|
||||
self._history_refresh_timer = timer
|
||||
timer.start(0)
|
||||
def _on_scheduled_task_done(self, task_id: str, ok: bool) -> None:
|
||||
"""Desktop notification for a finished scheduled task (toast always,
|
||||
tray balloon when the window isn't focused), then refresh History —
|
||||
@@ -106,4 +113,5 @@ class SessionEventsMixin:
|
||||
"""Project được tạo/sửa/xoá: gom nhóm lại cột lịch sử và cập nhật nhãn thư mục."""
|
||||
self.sidebar.refresh() # History regroups by project
|
||||
self.cowork._apply_output_folder_label() # project may have been renamed
|
||||
self.structure._refresh_project_combo() # GraphRAG's project lock list follows too
|
||||
if getattr(self, "structure", None) is not None:
|
||||
self.structure._refresh_project_combo() # GraphRAG's project lock list follows too
|
||||
|
||||
@@ -32,10 +32,10 @@ class TopBarMixin:
|
||||
``_build_account_row`` ngay bên dưới, chỉ khác chỗ đặt trên màn hình.
|
||||
"""
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QStyle
|
||||
from ...i18n import tr
|
||||
from ...ui.icons import icon as _icon
|
||||
from .rail_metrics import _NAV_ROW_GAP, _NAV_ROW_INSET
|
||||
from .rail_metrics import _NAV_ROW_INSET, _NAV_SETTINGS_GAP
|
||||
|
||||
# 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
|
||||
@@ -59,11 +59,17 @@ class TopBarMixin:
|
||||
# that number. Adding it again here made the row taller than the button
|
||||
# (28 wanted, 20 given), which both clipped the icon and pushed the text
|
||||
# 8px below an even pitch with Dashboard / Giám sát.
|
||||
srow.setContentsMargins(_NAV_ROW_INSET, 0, 8, 0)
|
||||
srow.setSpacing(_NAV_ROW_GAP)
|
||||
srow.setContentsMargins(_NAV_ROW_INSET + 2, 0, 8, 0)
|
||||
# Khe giữa icon và chữ phải là khe của STYLE, không phải nhịp riêng của
|
||||
# rail: delegate của cây vẽ chữ ngay sau hộp icon, cách đúng
|
||||
# ``PM_FocusFrameHMargin + 1``. Đặt ``_NAV_ROW_GAP + 4`` (=10) ở đây cộng
|
||||
# với 10px lề trái và hộp icon 22px thành 42 — trong khi Dashboard /
|
||||
# Giám sát đặt chữ ở 35, nên hàng Cài đặt thụt phải 7px.
|
||||
srow.setSpacing(
|
||||
self.nav_bottom.style().pixelMetric(QStyle.PM_FocusFrameHMargin) + 1)
|
||||
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_icon.setPixmap(_icon("gear").pixmap(16, 16))
|
||||
self._nav_settings_icon.setFixedSize(22, 16)
|
||||
self._nav_settings_text = QLabel(tr("app.settings"))
|
||||
srow.addWidget(self._nav_settings_icon)
|
||||
srow.addWidget(self._nav_settings_text)
|
||||
@@ -71,6 +77,12 @@ class TopBarMixin:
|
||||
# The first _rebuild_nav() ran before this button existed (it is what
|
||||
# fills the list this row belongs under), so take the height here too.
|
||||
self._nav_settings_btn.setFixedHeight(self.nav_bottom.sizeHintForRow(0))
|
||||
# Khe TRÊN hàng Cài đặt, xin thẳng từ layout — thanh rail đặt
|
||||
# ``setSpacing(0)`` nên không có khoảng nào sẵn, và margin trong QSS thì
|
||||
# không mua được pixel nào (xem ``_NAV_SETTINGS_GAP``). Cài đặt là việc
|
||||
# khác với nhóm Dashboard/Giám sát ngay trên nó; dán sát vào thì hai thứ
|
||||
# đọc thành một khối.
|
||||
nvl.addSpacing(_NAV_SETTINGS_GAP)
|
||||
nvl.addWidget(self._nav_settings_btn)
|
||||
self._account_row = self._build_account_row()
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from ...i18n import tr
|
||||
from ...ui.dialog_buttons import confirm
|
||||
|
||||
#: Ten project mac dinh. Co y KHONG dich — xem ghi chu trong ``_create``.
|
||||
_DEFAULT_PROJECT_NAME = "Project"
|
||||
@@ -51,7 +52,7 @@ class ProjectRow(QWidget):
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(6, 4, 6, 4)
|
||||
lay.setSpacing(0)
|
||||
lay.setSpacing(3)
|
||||
self.title_label = QLabel(name)
|
||||
self.counts_label = QLabel()
|
||||
self.counts_label.setObjectName("hint")
|
||||
@@ -89,6 +90,7 @@ def _row_layout_of(widget: QWidget) -> QLayout | None:
|
||||
return None
|
||||
|
||||
|
||||
|
||||
class ProjectEditingMixin:
|
||||
"""Danh sách project + CRUD + chế độ sửa. Trộn vào ``WorkspaceTab``.
|
||||
|
||||
@@ -132,10 +134,17 @@ class ProjectEditingMixin:
|
||||
# dung luat ma _new_btn da theo (_new_btn.setVisible(on_project) trong
|
||||
# WorkspaceTab._apply_pane_visibility) — hai nut nay phai theo y nhu vay.
|
||||
self.tabs.currentChanged.connect(self._sync_project_buttons)
|
||||
# Đổi project cũng phải đồng bộ lại: ``_load_current`` nạp form và đặt
|
||||
# ``_current_id`` rồi phát tín hiệu này, nhưng không đụng tới ba nút.
|
||||
self.project_selected.connect(self._sync_project_buttons)
|
||||
|
||||
self.project_list.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.project_list.customContextMenuRequested.connect(self._show_project_menu)
|
||||
|
||||
# Luật "mỗi thư mục một project" sống ở module riêng — xem
|
||||
# ``project_folder_rules.py`` về lý do nó không nằm trong file này.
|
||||
self.install_project_folder_rule()
|
||||
|
||||
self.set_project_editable(False)
|
||||
|
||||
# ---- chế độ chỉ-xem / sửa -------------------------------------------
|
||||
@@ -152,15 +161,14 @@ class ProjectEditingMixin:
|
||||
Bật: ngược lại, và nút Lưu chuyển sang màu xác nhận (token ``success``).
|
||||
"""
|
||||
self._project_editable = on
|
||||
has_project = bool(getattr(self, "_current_id", ""))
|
||||
|
||||
for field in self._editable_fields():
|
||||
# setReadOnly thay vì setEnabled: ô mờ đi thì không bôi đen copy
|
||||
# được nữa, mà đọc và copy chính là việc của chế độ chỉ-xem.
|
||||
field.setReadOnly(not on)
|
||||
self._browse_btn.setEnabled(on and has_project)
|
||||
self._save_btn.setEnabled(on and has_project)
|
||||
self._edit_btn.setEnabled(not on and has_project)
|
||||
# Ba nút không tự bật/tắt ở đây: ``_sync_project_buttons`` mới là nơi
|
||||
# duy nhất tính trạng thái của chúng, vì nó còn chạy cả khi người dùng
|
||||
# đổi project — lúc đó ``set_project_editable`` không được gọi.
|
||||
self._sync_project_buttons()
|
||||
|
||||
# Nút Lưu xanh lá khi đang sửa (hành động xác nhận), về màu nhấn mặc
|
||||
@@ -170,15 +178,25 @@ class ProjectEditingMixin:
|
||||
self._repolish(self._edit_btn)
|
||||
|
||||
def _sync_project_buttons(self, *_a) -> None:
|
||||
"""Ẩn "Sửa project" và "Lưu project" ngoài sub-tab Project.
|
||||
"""Đồng bộ CẢ hiện/ẩn LẪN bật/mờ của ba nút theo trạng thái hiện tại.
|
||||
|
||||
Chúng nằm trên hàng tiêu đề dùng chung, nên không tự ẩn là chúng hiện
|
||||
cả ở Cowork — nơi không có biểu mẫu project nào để sửa hay lưu.
|
||||
Ẩn ngoài sub-tab Project: chúng nằm trên hàng tiêu đề dùng chung, nên
|
||||
không tự ẩn là chúng hiện cả ở Cowork — nơi không có biểu mẫu project
|
||||
nào để sửa hay lưu.
|
||||
|
||||
Bật/mờ cũng tính ở đây chứ không ở ``set_project_editable``: đổi
|
||||
project KHÔNG đi qua hàm đó (``_load_current`` chỉ nạp lại form), nên
|
||||
để ở đó thì "Sửa project" giữ nguyên trạng thái tính từ lúc dựng —
|
||||
lúc chưa project nào được chọn — và cứ mờ mãi dù project đã mở.
|
||||
"""
|
||||
on_project = self.tabs.currentIndex() == self._project_tab_idx
|
||||
has_project = bool(getattr(self, "_current_id", ""))
|
||||
dang_sua = bool(getattr(self, "_project_editable", False))
|
||||
self._edit_btn.setVisible(on_project and has_project)
|
||||
self._save_btn.setVisible(on_project and has_project)
|
||||
self._edit_btn.setEnabled(not dang_sua and has_project)
|
||||
self._save_btn.setEnabled(dang_sua and has_project)
|
||||
self._browse_btn.setEnabled(dang_sua and has_project)
|
||||
|
||||
@staticmethod
|
||||
def _repolish(widget: QWidget) -> None:
|
||||
@@ -292,9 +310,8 @@ class ProjectEditingMixin:
|
||||
project = load_project(pid) if pid else None
|
||||
if project is None:
|
||||
return
|
||||
if QMessageBox.question(
|
||||
self, tr("workspace.delete"),
|
||||
tr("workspace.delete_confirm", name=project.name)) != QMessageBox.Yes:
|
||||
if not confirm(self, tr("workspace.delete"),
|
||||
tr("workspace.delete_confirm", name=project.name)):
|
||||
return
|
||||
delete_project(pid)
|
||||
self._current_id = ""
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Luật "mỗi thư mục làm việc chỉ thuộc về MỘT project".
|
||||
|
||||
Tách khỏi ``project_editing.py`` chứ không nhét thêm vào đó: file kia đã gom
|
||||
bốn tính năng và thêm luật này là chạm trần 400 dòng của
|
||||
``scripts/check_loc.py``. Đây cũng là một mối quan tâm riêng — nó không nói về
|
||||
việc *sửa* một project mà về việc hai project không được giẫm lên nhau.
|
||||
|
||||
Luật có hai nửa, cố ý không đối xứng:
|
||||
|
||||
* **Chặn lúc CHỌN.** Ba nơi đặt được thư mục làm việc (nút "Đổi" ở màn Project,
|
||||
thư mục cloud, nút chọn thư mục trong tab Cowork) đều đi qua
|
||||
:func:`folder_taken_blocked`, để cả ba chặn giống hệt nhau. Không chặn ở
|
||||
"Lưu project": nút đó chỉ ghi tên/mô tả/chỉ dẫn, chặn ở đó sẽ khoá luôn việc
|
||||
đổi tên một project lỡ đang trùng thư mục.
|
||||
* **Cảnh báo cho cái ĐANG sai.** Dữ liệu cũ có thể đã có hai project trỏ vào
|
||||
cùng một thư mục, mà nửa trên chỉ chặn từ nay trở đi. Nhãn dưới ô "Thư mục
|
||||
làm việc" nói ra điều đó và để người dùng tự đổi — sửa hộ là tự ý đụng vào
|
||||
dữ liệu của họ.
|
||||
|
||||
Phép so trùng nằm ở ``core/projects.py::folder_conflict`` (thuần, không Qt).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import QLabel, QLayout, QMessageBox, QWidget
|
||||
|
||||
from ...i18n import tr
|
||||
from .project_editing import _row_layout_of
|
||||
|
||||
|
||||
def _layout_chua(layout: QLayout, con: QLayout) -> "tuple | None":
|
||||
"""``(layout_cha, vị_trí)`` của ``con`` bên trong ``layout``, duyệt đệ quy."""
|
||||
for i in range(layout.count()):
|
||||
item = layout.itemAt(i)
|
||||
ben_trong = item.layout()
|
||||
if ben_trong is con:
|
||||
return layout, i
|
||||
if ben_trong is not None:
|
||||
tim = _layout_chua(ben_trong, con)
|
||||
if tim is not None:
|
||||
return tim
|
||||
return None
|
||||
|
||||
|
||||
def folder_taken_blocked(parent: QWidget, path: str, ignore_id: str) -> bool:
|
||||
"""``True`` nếu ``path`` đã thuộc project khác — và đã báo cho người dùng.
|
||||
|
||||
Dùng chung cho cả ba nơi đặt được thư mục làm việc (nút "Đổi" ở màn
|
||||
Project, thư mục cloud, và nút chọn thư mục trong tab Cowork), để cả ba
|
||||
chặn giống hệt nhau thay vì mỗi nơi tự nghĩ ra một luật.
|
||||
|
||||
Chặn ở lúc CHỌN chứ không ở lúc Lưu: "Lưu project" chỉ ghi tên, mô tả và
|
||||
chỉ dẫn — chặn ở đó sẽ khoá luôn việc đổi tên một project lỡ đang trùng
|
||||
thư mục, tức phạt người dùng vì một trạng thái họ chưa kịp sửa.
|
||||
"""
|
||||
from ...core.projects import folder_conflict
|
||||
|
||||
khac = folder_conflict(path, ignore_id=ignore_id)
|
||||
if khac is None:
|
||||
return False
|
||||
QMessageBox.warning(parent, tr("workspace.folder_taken_title"),
|
||||
tr("workspace.folder_taken_body", name=khac.name,
|
||||
folder=str(khac.workspace_dir())))
|
||||
return True
|
||||
|
||||
|
||||
|
||||
class ProjectFolderRuleMixin:
|
||||
"""Nửa giao diện của luật. Trộn vào ``WorkspaceTab``."""
|
||||
|
||||
def install_project_folder_rule(self) -> None:
|
||||
"""Dựng nhãn cảnh báo và nối nó vào việc đổi project.
|
||||
|
||||
Gọi từ ``install_project_editing``, tức sau khi form đã dựng xong.
|
||||
"""
|
||||
self._folder_warn_lbl = QLabel()
|
||||
self._folder_warn_lbl.setObjectName("warning") # màu lấy từ theme/
|
||||
self._folder_warn_lbl.setWordWrap(True)
|
||||
self._folder_warn_lbl.hide()
|
||||
self._gan_nhan_canh_bao_thu_muc()
|
||||
self.project_selected.connect(self._sync_folder_warning)
|
||||
|
||||
def rebind_workspace_folder(self) -> None:
|
||||
"""Thư mục làm việc vừa đổi — trỏ lại những màn đang bám vào nó.
|
||||
|
||||
Đổi thư mục KHÔNG làm project id đổi, nên không có gì trong luồng
|
||||
chọn project chạy lại: ``_pick_folder`` ghi ``output_dir`` rồi dừng.
|
||||
Tab Thư mục và màn GraphRAG vì thế giữ nguyên đường dẫn cũ — tên
|
||||
project vẫn đúng nên nhìn qua tưởng ổn, nhưng GraphRAG quét nhầm
|
||||
thư mục.
|
||||
|
||||
Cố ý KHÔNG gọi ``_load_current``: hàm đó nạp lại cả biểu mẫu từ đĩa,
|
||||
nên gọi nó lúc người dùng đang sửa dở Tên/Mô tả là xoá mất phần chưa
|
||||
lưu.
|
||||
"""
|
||||
from ...core.projects import load_project
|
||||
|
||||
pid = getattr(self, "_current_id", "")
|
||||
project = load_project(pid) if pid else None
|
||||
if project is None:
|
||||
return
|
||||
if getattr(self, "_folder", None) is not None:
|
||||
self._folder.set_project_root(str(project.workspace_dir()))
|
||||
if getattr(self, "_structure", None) is not None:
|
||||
self._structure.set_workspace_project(pid)
|
||||
# Thư mục mới có thể vừa gỡ bỏ (hoặc tạo ra) một cảnh báo dùng chung.
|
||||
self._sync_folder_warning()
|
||||
|
||||
def _gan_nhan_canh_bao_thu_muc(self) -> None:
|
||||
"""Chèn nhãn cảnh báo ngay DƯỚI hàng chứa ô Thư mục làm việc.
|
||||
|
||||
Chèn từ đây thay vì thêm dòng vào ``_build_project_tab``: file
|
||||
``ui/workspace_tab.py`` đang vượt trần của ``scripts/check_loc.py``,
|
||||
nên mọi dòng mới đều phải tránh nó (cùng lý do nút "Sửa project" được
|
||||
chèn bằng ``_row_layout_of``).
|
||||
"""
|
||||
hang = _row_layout_of(self.folder_lbl)
|
||||
cha = self.folder_lbl.parentWidget()
|
||||
if hang is None or cha is None or cha.layout() is None:
|
||||
return
|
||||
tim = _layout_chua(cha.layout(), hang)
|
||||
if tim is None:
|
||||
return
|
||||
layout, vi_tri = tim
|
||||
layout.insertWidget(vi_tri + 1, self._folder_warn_lbl)
|
||||
|
||||
def _sync_folder_warning(self, *_a) -> None:
|
||||
"""Hiện/ẩn cảnh báo "thư mục đang dùng chung" theo project đang mở."""
|
||||
from ...core.projects import folder_conflict, load_project
|
||||
|
||||
pid = getattr(self, "_current_id", "")
|
||||
project = load_project(pid) if pid else None
|
||||
khac = (folder_conflict(project.workspace_dir(), ignore_id=pid)
|
||||
if project is not None else None)
|
||||
if khac is None:
|
||||
self._folder_warn_lbl.hide()
|
||||
return
|
||||
self._folder_warn_lbl.setText(
|
||||
tr("workspace.folder_shared_warning", name=khac.name))
|
||||
self._folder_warn_lbl.show()
|
||||
@@ -278,10 +278,17 @@ class AnthropicProvider(Provider):
|
||||
raise ProviderError(f"Anthropic: {evt.get('error', {}).get('message', 'error')}")
|
||||
resp.close()
|
||||
break # stream finished normally (or cancelled)
|
||||
except requests.RequestException as exc:
|
||||
except Exception as exc: # noqa: BLE001 — lọc lại ngay bên dưới
|
||||
resp.close()
|
||||
# Huỷ giữa chừng đóng socket, và urllib3 ném AttributeError
|
||||
# ("'NoneType' object has no attribute 'read'") chứ KHÔNG phải
|
||||
# RequestException — bắt hẹp là lỗi đó lọt ra ngoài và người dùng
|
||||
# thấy một lỗi Python đỏ thay vì "đã dừng". Chỉ nuốt khi thật sự
|
||||
# đang huỷ; lỗi khác vẫn ném tiếp nguyên vẹn.
|
||||
if self._is_cancelled(cancel):
|
||||
break
|
||||
if not isinstance(exc, requests.RequestException):
|
||||
raise
|
||||
if text_parts or blocks:
|
||||
if on_text:
|
||||
on_text("\n⚠ Kết nối bị ngắt giữa chừng — hiển thị phần đã nhận được.\n")
|
||||
|
||||
@@ -254,13 +254,19 @@ class OpenAICompatProvider(Provider):
|
||||
slot["args"] += fn["arguments"]
|
||||
resp.close()
|
||||
break # stream finished normally (or cancelled)
|
||||
except requests.RequestException as exc:
|
||||
except Exception as exc: # noqa: BLE001 — lọc lại ngay bên dưới
|
||||
resp.close()
|
||||
# If cancel was requested, close cleanly without retry
|
||||
# Huỷ giữa chừng đóng socket, và urllib3 ném AttributeError
|
||||
# ("'NoneType' object has no attribute 'read'") chứ KHÔNG phải
|
||||
# RequestException — bắt hẹp là lỗi đó lọt ra ngoài và người dùng
|
||||
# thấy một lỗi Python đỏ thay vì "đã dừng". Chỉ nuốt khi thật sự
|
||||
# đang huỷ; lỗi khác vẫn ném tiếp nguyên vẹn.
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
break
|
||||
if self._is_cancelled(cancel):
|
||||
break
|
||||
if not isinstance(exc, requests.RequestException):
|
||||
raise
|
||||
if text_parts or tool_acc:
|
||||
# Partial answer already on screen — keep it, note the cut.
|
||||
if on_text:
|
||||
|
||||
@@ -19,6 +19,14 @@ set "APPHOME=%LOCALAPPDATA%\CoworkLocal"
|
||||
set "VENV=%APPHOME%\venv"
|
||||
set "LAUNCHER=%APPHOME%\launcher"
|
||||
|
||||
rem An cua so console NGAY TU DAU, ke ca trong luc kiem tra ben duoi — khong
|
||||
rem chi truoc luc chay app. Moi cho bao loi (echo + pause) ben duoi tu hien
|
||||
rem lai cua so truoc khi in, de thong bao van doc duoc.
|
||||
set "CONSOLE_VIS=%REPO%\scripts\console_visibility.ps1"
|
||||
if exist "%CONSOLE_VIS%" (
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 0 >nul 2>&1
|
||||
)
|
||||
|
||||
rem --------------------------------------------------------------------------
|
||||
rem 1. Chon trinh thong dich
|
||||
rem
|
||||
@@ -38,6 +46,7 @@ if exist "%VENV%\Scripts\python.exe" (
|
||||
)
|
||||
|
||||
if not defined RUNPY (
|
||||
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
|
||||
echo.
|
||||
echo [LỖI] Không tìm thấy Python. Chạy install.bat trước đã.
|
||||
echo.
|
||||
@@ -51,6 +60,7 @@ rem biet la phai chay install.bat.
|
||||
if not exist "%VENV%\Scripts\python.exe" (
|
||||
!RUNPY! -c "import PySide6" >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
|
||||
echo.
|
||||
echo [LỖI] Thư viện chưa được cài. Chạy install.bat trước đã.
|
||||
echo.
|
||||
@@ -94,6 +104,7 @@ if /I "%REPO_NAME%"=="cowork_local" (
|
||||
if exist "!PKGPATH!\cowork_local" rmdir "!PKGPATH!\cowork_local" >nul 2>&1
|
||||
mklink /J "!PKGPATH!\cowork_local" "%REPO%" >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
|
||||
echo.
|
||||
echo [LOI] Khong tao duoc lien ket thu muc:
|
||||
echo "!PKGPATH!\cowork_local" -> "%REPO%"
|
||||
@@ -112,6 +123,7 @@ rem Chot lai: goi phai THAT SU nhin thay duoc qua duong dan vua dung. Khong co
|
||||
rem buoc nay thi mot junction hong chi hien ra duoi dang loi Python kho hieu
|
||||
rem ("'cowork_local' is a package and cannot be directly executed").
|
||||
if not exist "!PKGPATH!\cowork_local\__main__.py" (
|
||||
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
|
||||
echo.
|
||||
echo [LOI] Khong tim thay cowork_local\__main__.py qua duong dan:
|
||||
echo "!PKGPATH!"
|
||||
@@ -138,10 +150,20 @@ if defined PYTHONPATH (
|
||||
set "PYTHONIOENCODING=utf-8"
|
||||
cd /d "%REPO%"
|
||||
|
||||
rem --------------------------------------------------------------------------
|
||||
rem 4. Chay app
|
||||
rem
|
||||
rem App la GUI (Qt), khong can console — cua so console da bi an tu dau file
|
||||
rem roi (xem khoi CONSOLE_VIS phia tren), chi hien lai NEU app thoat loi, de
|
||||
rem thong bao loi ben duoi van doc duoc.
|
||||
rem --------------------------------------------------------------------------
|
||||
!RUNPY! -m cowork_local %*
|
||||
set "RC=%ERRORLEVEL%"
|
||||
|
||||
if not "%RC%"=="0" (
|
||||
if exist "%CONSOLE_VIS%" (
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
|
||||
)
|
||||
echo.
|
||||
echo Ứng dụng thoát với mã lỗi %RC%. Xem thông báo ở trên.
|
||||
echo.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Ẩn/hiện cửa sổ console hiện tại — dùng bởi run.bat để không hiện cửa sổ
|
||||
cmd đen suốt phiên chạy app (app là GUI Qt, không cần console), nhưng vẫn
|
||||
hiện lại được nếu app thoát lỗi để người dùng đọc thông báo.
|
||||
|
||||
.PARAMETER Mode
|
||||
0 = ẩn (SW_HIDE), 5 = hiện lại (SW_SHOW).
|
||||
#>
|
||||
param(
|
||||
[int]$Mode = 0
|
||||
)
|
||||
|
||||
Add-Type -Name Win32 -Namespace CoworkLocalNative -MemberDefinition @"
|
||||
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
||||
[DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow();
|
||||
"@
|
||||
|
||||
$hwnd = [CoworkLocalNative.Win32]::GetConsoleWindow()
|
||||
if ($hwnd -ne [IntPtr]::Zero) {
|
||||
[CoworkLocalNative.Win32]::ShowWindow($hwnd, $Mode) | Out-Null
|
||||
}
|
||||
@@ -73,6 +73,38 @@ _MODERATE_PATTERNS = [
|
||||
r'\b(test|pytest|jest|mocha)\b',
|
||||
]
|
||||
|
||||
# Tools that reach the network over ICMP/raw sockets/direct DNS instead of an
|
||||
# HTTP(S) connection — none of them read HTTP_PROXY/HTTPS_PROXY, so
|
||||
# core/deps.py::network_blocked_env()'s proxy-env-var block (the only network
|
||||
# control this sandbox actually enforces) has no effect on them at all. Used
|
||||
# by command_bypasses_network_proxy() to deny these BY NAME when the user has
|
||||
# "Chặn mạng cho lệnh do agent chạy" on, since the proxy trick alone silently
|
||||
# lets them through (see DF-005 in Defect Management).
|
||||
_NETWORK_PROXY_BYPASS_PATTERNS = [
|
||||
r'\bping\b', r'\btracert\b', r'\btraceroute\b', r'\bnslookup\b', r'\bdig\b',
|
||||
r'\btelnet\b', r'\bftp\b', r'\bsftp\b', r'\bscp\b', r'\bssh\b',
|
||||
r'\bnc\b', r'\bncat\b', r'\bnetcat\b', r'\barp\b',
|
||||
r'\btest-netconnection\b', r'\btest-connection\b', r'\bresolve-dnsname\b',
|
||||
]
|
||||
|
||||
|
||||
def command_bypasses_network_proxy(command: str) -> Optional[str]:
|
||||
"""Tên công cụ mạng đầu tiên khớp trong ``command`` mà không tôn trọng
|
||||
HTTP_PROXY/HTTPS_PROXY — None nếu không có công cụ nào như vậy.
|
||||
|
||||
``network_blocked_env()`` chỉ set biến proxy, nên chỉ chặn được các công
|
||||
cụ có ĐỌC biến đó (curl/pip/requests...). ``ping`` (ICMP), ``nslookup``
|
||||
(DNS trực tiếp), ``ssh``/``ftp`` (TCP thô)... đều đi qua giao thức khác,
|
||||
biến proxy không có tác dụng gì với chúng — phải chặn riêng theo tên lệnh
|
||||
khi ``block_network`` đang bật.
|
||||
"""
|
||||
cmd_lower = command.lower()
|
||||
for pattern in _NETWORK_PROXY_BYPASS_PATTERNS:
|
||||
m = re.search(pattern, cmd_lower, re.IGNORECASE)
|
||||
if m:
|
||||
return m.group()
|
||||
return None
|
||||
|
||||
|
||||
def classify_command(command: str, is_cowork_mode: bool = True) -> RiskResult:
|
||||
"""Chấm điểm rủi ro một lệnh shell.
|
||||
|
||||
@@ -233,11 +233,20 @@ class AppContext:
|
||||
connections across calls/turns (spawning a subprocess per turn would
|
||||
be slow and wasteful). A server/connector that fails to connect is
|
||||
skipped, not a hard failure for the turn."""
|
||||
# Sandbox Security Layer blocks agent-owned network connectors before
|
||||
# they can spawn a server or issue a REST request — and stops the ones
|
||||
# already running, which could otherwise keep talking to the network.
|
||||
blocked = bool(self.config.agent_security.get("block_network", False))
|
||||
if blocked:
|
||||
self.stop_mcp_connections()
|
||||
# Master switch (Monitoring → Tools → Connector): when the admin turns
|
||||
# "Connect to external" off, the agent connects to NO external
|
||||
# connectors/MCP at all — no subprocesses spawned, no REST calls.
|
||||
if not self.config.connect_external:
|
||||
return [], None
|
||||
from .core.ms365_local import build_ms365_local_tools
|
||||
if blocked: # the locally-synced OneDrive folders need no network
|
||||
return build_ms365_local_tools(self.config)
|
||||
from .core.ext_connectors import build_ext_connector_tools
|
||||
from .core.mcp_client import build_mcp_tools as _merge_mcp_tools
|
||||
from .core.tools import combine_tool_sources
|
||||
@@ -272,7 +281,6 @@ class AppContext:
|
||||
|
||||
# Locally-synced OneDrive/SharePoint (no sign-in) — reads/writes the
|
||||
# OneDrive-desktop-synced folders directly, gated on ms365.connectors.
|
||||
from .core.ms365_local import build_ms365_local_tools
|
||||
local_tools, local_executor = build_ms365_local_tools(self.config)
|
||||
|
||||
return combine_tool_sources((mcp_tools, mcp_executor), (ext_tools, ext_executor),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user