CI / test (push) Canceled after 0s
## Summary Nhánh `feature/perf-ui-logic`: tối ưu hiệu năng/UI, sửa lỗi workspace và điều hướng, và làm cho công tắc **"Block network for agent-run commands"** chặn thật mọi đường ra mạng của app, **trừ nhà cung cấp AI**. **Chặn mạng (b78d483,8c497cf,10b8379)** - Bộ kiểm tra chung `application/network/network_guard.py`, nối vào cấu hình đang chạy ở Composition Root: đổi công tắc trong Settings là có hiệu lực ngay. - Lệnh shell của agent và task script chạy trong **Windows AppContainer không có quyền mạng**: kernel chặn socket, ping, DNS, Invoke-WebRequest… Không cần quyền admin. Không cô lập được thì lệnh bị từ chối, không chạy khi mạng còn mở. macOS dùng `sandbox-exec`, Linux dùng `unshare --net`. - Bật chặn thì: dừng MCP đang chạy, không khởi động server mới, từ chối lời gọi connector; Microsoft 365 (đăng nhập, Graph, đồng bộ cloud, rules, mail), Teams, nút Test REST/Jira/MCP, link đính kèm task, pip tự cài và tài nguyên web trong xem trước HTML đều bị từ chối. - Vẫn dùng được: chat, tải danh sách model, thử model; tool OneDrive đã đồng bộ trên máy. - Công tắc **mặc định tắt** khi mở app lần đầu; nhãn giữ nguyên như cũ. - Xem trước HTML trong tab Folder giờ hiện được ảnh/CSS/JS từ web khi mạng mở (trước đây trang `file://` không tải được). - Sửa lỗi app văng khi chuyển tab Graph → Folder: profile WebEngine của trang xem trước bị huỷ trước trang (`0xc0000409` trong Qt6Core.dll); giờ dùng một profile chung thuộc QApplication. - Không cấp quyền AppContainer kế thừa lên thư mục chứa PySide6 (nếu có, Chromium không nạp được `Qt6WebEngineCore.dll` và tab Graph trắng). - Cột mục lục trong Settings tính độ rộng theo kiểu chữ của mục đang chọn, "Sandbox Security Layer" không còn bị cắt. **Các commit khác trong nhánh** - `b7a41b3` mỗi thư mục làm việc chỉ thuộc về một project · `bbdf146` bật nút Sửa project khi đã có project đang mở - `35f24e0`, `cc8d5c8`, `2e3e719`, `c699beb` canh hàng / khoảng cách thanh điều hướng - `2759ed9` không refresh workspace khi chuyển tab Cowork · `7607f44` checkpoint hiệu năng và UI - `8548c1e` chặn tool mạng của agent · `caf3b74` renderer GraphRAG native trên macOS · `c00b83c` khoảng cách metadata hàng project · `a04f8a9` ẩn picker workspace cloud ## Change Type - [x] Cowork feature - [x] Bug fix - [ ] Core AI contribution - [x] Test / hardening - [x] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? - Mọi đường ra mạng do app tự mở, trừ nhà cung cấp AI (xem Summary). - Test: `tests/test_network_guard_lanes.py` (có bài chạy AppContainer thật trên Windows), `tests/ui/test_html_preview_remote_images.py`. What is intentionally NOT included? - Chặn cả nhà cung cấp AI / chạy model trên máy (Phương án 2). - Terminal người dùng tự gõ trong tab Folder, sinh ảnh, cơ chế tự tin chứng chỉ lạ (`tls_trust`). - Huy hiệu trạng thái "đang chặn" trên thanh trên cùng. ## Validation - [x] Unit tests - [x] Integration tests - [x] Manual verification - [x] Regression check Commands / evidence: - `python -m pytest tests/test_network_guard_lanes.py tests/test_sandbox_block_network.py tests/ui -q` → chỉ còn 1 bài fail, fail cả trên `b7a41b3` (nhãn `ProjectRow` 'Project' chưa dịch, `tests/ui/test_i18n_khong_con_chu_cu.py`). - `python -m pytest tests -q --ignore=tests/ui` → 4 bài fail, cả 4 cũng fail trên `b7a41b3` (`test_canonical_audit_logger`, 2 bài `test_mcp_audit_security`, `test_monitoring_tab_container`). - Chạy cả `tests` trong một lượt thì treo ở các test dựng MainWindow trong `tests/ui`; `b7a41b3` cũng treo đúng chỗ đó. - `check_imports.py` và `check_orphan_modules.py` PASS. `check_loc.py` báo 9 file quá dài, giống hệt trước khi sửa (không file nào do nhánh này làm dài thêm). - Kiểm tra tay trên Windows 11: trong AppContainer, Python báo `WinError 10013`, ping/nslookup/PowerShell/curl đều không ra được mạng; cmd, git, python chạy bình thường. - Kiểm tra tay trên Windows 11: xem trước HTML tải được 4/4 tài nguyên web khi mạng mở, 0/4 khi bật chặn; tab Graph hoạt động; tạo/huỷ trang xem trước nhiều lần không còn cảnh báo profile của Qt. ## Security Impact Permission / credential / network / customer data impact: - Network: khi bật công tắc, chỉ nhà cung cấp AI còn ra mạng; nội dung chat vẫn gửi tới nhà cung cấp AI. - Permission: lần đầu chạy lệnh trong sandbox, app **thêm quyền (ACE) cho SID AppContainer** trên thư mục làm việc (ghi), thư mục cài Python gốc (đọc), gốc venv và `Scripts` (đọc). Không xoá quyền nào. Thư mục chứa PySide6 không bao giờ nhận quyền kế thừa; một quyền kế thừa sai trên venv (từ bản dev trước) được tự gỡ. - Credential: không đổi. Khi chặn, trạng thái đăng nhập M365 được đọc thẳng từ kho token trên máy, không dựng MSAL. ## Compatibility - [x] No breaking change - [ ] Breaking change documented Ghi chú: `block_network` mặc định đổi từ bật sang tắt cho cấu hình mới; máy đã lưu `true` thì giữ nguyên. Khi đang chặn, lệnh dùng công cụ cài trong thư mục người dùng (ngoài Program Files) có thể báo Access denied; thư viện trong venv của app không dùng được trong sandbox. ## Reviewer Notes - `infrastructure/sandbox/appcontainer_process.py` gọi Win32 bằng ctypes (CreateAppContainerProfile, CreateProcessW với SECURITY_CAPABILITIES) và dùng `icacls` để cấp quyền: nên xem kỹ phần cấp quyền. - `tests/conftest.py` thêm fixture autouse gỡ `network_guard` sau mỗi test, vì `build_context()` gắn cổng này ở mức process. - `core/task_executors.py` đang đúng bằng trần LOC nên `_run_script` được tách sang `core/task_script.py`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: minhanhpkpro <minhanhpkpro@gmail.com> Co-authored-by: Duy Le Huu <duylh19@fpt.com> Co-authored-by: thanhnv <thanhnv.ip@gmail.com> Reviewed-on: #13
254 lines
10 KiB
Python
254 lines
10 KiB
Python
"""Conversation persistence with a per-conversation file model.
|
|
|
|
Each conversation is stored as one JSON file::
|
|
|
|
{"kind": "cowork"|"code", "session_id": str, "title": str,
|
|
"created": ISO8601, "messages": [...canonical...]}
|
|
|
|
File name: ``<kind>__<session_id>.json`` so the sidebar can group by kind and
|
|
sort by recency. History can live locally or in a OneDrive folder (resolved by
|
|
``AppConfig.history_dir``).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
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]
|
|
|
|
|
|
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.
|
|
|
|
Dùng khi người dùng chưa tự đặt tên — cắt gọn cho vừa một dòng danh sách.
|
|
"""
|
|
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 "(empty)"
|
|
|
|
|
|
def save_conversation(
|
|
directory: Path,
|
|
kind: str,
|
|
session_id: str,
|
|
messages: List[Dict[str, Any]],
|
|
title: str = "",
|
|
created: str = "",
|
|
inputs: List[str] | None = None,
|
|
outputs: List[str] | None = None,
|
|
project_id: str = "",
|
|
) -> Path:
|
|
"""Ghi một hội thoại xuống ``<kind>__<session_id>.json``.
|
|
|
|
Ghi nguyên tử (R06-T02). Cờ ghim và project_id của lần lưu trước được GIỮ
|
|
LẠI: hàm này bị gọi tự động sau mỗi lượt chat, ghi đè chúng sẽ âm thầm bỏ
|
|
ghim và đẩy hội thoại ra khỏi project của nó.
|
|
"""
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
path = directory / f"{kind}__{session_id}.json"
|
|
pinned = False # preserve pin flag + project across autosaves
|
|
prev_project = ""
|
|
if path.exists():
|
|
try:
|
|
prev = json.loads(path.read_text(encoding="utf-8"))
|
|
pinned = bool(prev.get("pinned", False))
|
|
prev_project = prev.get("project_id", "")
|
|
except (OSError, json.JSONDecodeError):
|
|
pinned = False
|
|
payload = {
|
|
"kind": kind,
|
|
"session_id": session_id,
|
|
"title": title or derive_title(messages),
|
|
"created": created or datetime.now().isoformat(timespec="seconds"),
|
|
"pinned": pinned,
|
|
# A conversation belongs to a project (Claude-Projects style); legacy
|
|
# files without one fall back to the default project.
|
|
"project_id": project_id or prev_project or "default",
|
|
"inputs": list(inputs or []),
|
|
"outputs": list(outputs or []),
|
|
"messages": messages,
|
|
}
|
|
# 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
|
|
|
|
|
|
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
|
|
|
|
|
|
def rename_conversation(path, new_title: str) -> None:
|
|
"""Đổi tiêu đề một hội thoại và ghi lại (nguyên tử)."""
|
|
from ..infrastructure.persistence.json.atomic_write import write_json
|
|
|
|
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:
|
|
"""Ghim/bỏ ghim một hội thoại để nó nằm trên đầu danh sách."""
|
|
from ..infrastructure.persistence.json.atomic_write import write_json
|
|
|
|
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]:
|
|
"""Đọc một hội thoại; file hỏng hoặc không đọc được thì trả về dict rỗng thay
|
|
vì ném lỗi — một file hỏng không được phép làm chết cả danh sách lịch sử.
|
|
"""
|
|
try:
|
|
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return {"kind": "", "title": "(read error)", "messages": []}
|
|
if isinstance(data, list): # tolerate legacy format
|
|
data = {"kind": "", "title": derive_title(data), "messages": data}
|
|
return data
|
|
|
|
|
|
def _matches_query(query: str, title: str, messages: List[Dict[str, Any]]) -> bool:
|
|
"""True if ``query`` (already lowercased) appears in the title or in any
|
|
message's text content — a conversation "matches" by title OR content."""
|
|
if query in (title or "").lower():
|
|
return True
|
|
for m in messages or []:
|
|
content = m.get("content")
|
|
if isinstance(content, str) and query in content.lower():
|
|
return True
|
|
return False
|
|
|
|
|
|
def history_dirs() -> list:
|
|
"""Các cặp ``(project_id, thư mục lịch sử)`` của MỌI project, cộng thư mục
|
|
mặc định cho hội thoại chưa thuộc project nào.
|
|
|
|
Có hàm này vì lịch sử KHÔNG nằm chung một chỗ, mà nằm trong thư mục làm việc
|
|
của từng project. Ai chỉ gọi ``list_conversations()`` một lần sẽ chỉ thấy
|
|
hội thoại của project đang mở — hoặc, nếu gọi không tham số, không thấy cái
|
|
nào cả. Đó chính là hai lỗi đã xảy ra: khung "Tất cả project…" hiện nhóm
|
|
rỗng cho mọi project trừ một, và mọi dòng project đều đếm "0 đoạn chat".
|
|
"""
|
|
from ..config import HISTORY_DIR
|
|
from .projects import list_projects, project_history_dir
|
|
|
|
pairs = [("default", HISTORY_DIR)]
|
|
for project in list_projects():
|
|
pairs.append((project.project_id, project_history_dir(project)))
|
|
return pairs
|
|
|
|
|
|
def list_conversations_by_project(pairs, query: str = "") -> List[Dict[str, Any]]:
|
|
"""Gộp lịch sử hội thoại của NHIỀU project. ``pairs`` là các cặp
|
|
``(project_id, directory)``.
|
|
|
|
Lịch sử KHÔNG nằm chung một chỗ: ``WorkspaceTab`` đặt
|
|
``config._project_history_dir`` thành ``<workspace của project>/.cowork_history``
|
|
mỗi lần người dùng chọn project khác, nên ``config.history_dir()`` chỉ trả về
|
|
thư mục của project ĐANG mở. Một lần gọi :func:`list_conversations` vì thế
|
|
chỉ thấy được hội thoại của project đó — khung "Tất cả project…" dựng đủ
|
|
tiêu đề nhóm cho mọi project nhưng mọi nhóm trừ một đều rỗng.
|
|
|
|
Thư mục là chủ sở hữu có thẩm quyền: hội thoại nằm trong thư mục làm việc của
|
|
project nào thì thuộc project đó, kể cả khi trường ``project_id`` ghi trong
|
|
file đã cũ (project bị đổi thư mục chẳng hạn).
|
|
"""
|
|
seen: set = set()
|
|
items: List[Dict[str, Any]] = []
|
|
for project_id, directory in pairs:
|
|
if directory is None:
|
|
continue
|
|
for meta in list_conversations(directory, query=query):
|
|
key = str(meta["path"])
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
if project_id:
|
|
meta["project_id"] = project_id
|
|
items.append(meta)
|
|
# Cùng thứ tự mà list_conversations dùng: ghim lên đầu, rồi mới nhất trước.
|
|
items.sort(key=lambda d: (not d["pinned"], -d["mtime"]))
|
|
return items
|
|
|
|
|
|
def list_conversations(directory: Optional[Path] = None, query: str = "") -> List[Dict[str, Any]]:
|
|
"""List saved conversations, most recent first (pinned always on top).
|
|
|
|
``query`` (from the sidebar's search box), when non-empty, keeps only
|
|
conversations whose title OR any message's content contains it
|
|
(case-insensitive) — since every file is already parsed to build the
|
|
metadata below, this search costs no extra I/O over listing alone."""
|
|
if directory is None:
|
|
from ..config import HISTORY_DIR
|
|
directory = HISTORY_DIR
|
|
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]] = []
|
|
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):
|
|
continue
|
|
if isinstance(data, list):
|
|
data = {"kind": "", "title": derive_title(data), "messages": data}
|
|
title = data.get("title", path.stem)
|
|
if q and not _matches_query(q, title, data.get("messages", [])):
|
|
continue
|
|
items.append({
|
|
"path": path,
|
|
"kind": data.get("kind", ""),
|
|
"title": title,
|
|
"created": data.get("created", ""),
|
|
"session_id": data.get("session_id", path.stem),
|
|
"pinned": bool(data.get("pinned", False)),
|
|
"project_id": data.get("project_id", "") or "default",
|
|
"count": len(data.get("messages", [])),
|
|
"mtime": path.stat().st_mtime,
|
|
})
|
|
# 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
|