Compare commits

..
Author SHA1 Message Date
a03a740ea1 Feature/perf ui logic (#13)
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
2026-09-20 12:26:03 +00:00
17 changed files with 70 additions and 959 deletions
+2 -41
View File
@@ -12,7 +12,6 @@ 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
@@ -34,45 +33,6 @@ def new_session_id() -> str:
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.
@@ -80,7 +40,8 @@ def derive_title(messages: List[Dict[str, Any]]) -> str:
"""
for m in messages:
if m.get("role") == "user" and m.get("content"):
return shorten_title(" ".join(m["content"].split()))
text = " ".join(m["content"].split())
return text[:60] + ("…" if len(text) > 60 else "")
return "(empty)"
-2
View File
@@ -114,8 +114,6 @@ 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.",
-5
View File
@@ -229,11 +229,6 @@ 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"},
+1 -7
View File
@@ -99,9 +99,6 @@ 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
@@ -340,10 +337,7 @@ 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():
# Đã 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")
self.thinking.start("chat.running") # this conversation is still working
else:
self.thinking.stop()
self.composer.set_running(bool(self._active)) # Stop shows while anything runs
+4 -38
View File
@@ -13,7 +13,6 @@ 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
@@ -89,11 +88,7 @@ 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)")
# 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.title = (base[:60] + "…") if len(base) > 60 else base
self._notify_title()
# Reset the Plan panel so each message starts from a clean checklist (the
@@ -217,7 +212,6 @@ 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()
@@ -260,7 +254,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._dau_ket_thuc()
done = self.chat_view.add_success(tr("chat.done_marker")) # green done marker in the chat box
folder = self.workspace_dir()
if folder:
done.add_folder_link(str(folder), tr("chat.open_output_folder"))
@@ -274,32 +268,12 @@ 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 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:
if 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
@@ -312,10 +286,7 @@ class ChatTurnRunnerMixin:
self.composer.set_text(ctx["display_text"])
else:
self._persist_session(ctx)
# 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.status_message.emit(tr("chatpanel.failed", name=tr(f"app.tab.{self.kind}")))
self.turn_finished.emit({"error": err})
self._drain_queue()
@@ -340,11 +311,6 @@ 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()
-83
View File
@@ -1,83 +0,0 @@
"""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()
+58 -4
View File
@@ -27,7 +27,6 @@ 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
@@ -36,7 +35,7 @@ from cowork_local.theme import current_palette
from cowork_local.ui.icons import icon
class GraphRenderer(GraphProjectLockMixin, QWidget):
class GraphRenderer(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
@@ -61,8 +60,6 @@ class GraphRenderer(GraphProjectLockMixin, 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)
@@ -157,6 +154,63 @@ class GraphRenderer(GraphProjectLockMixin, 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."""
+2 -21
View File
@@ -40,9 +40,6 @@ 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)
@@ -211,29 +208,13 @@ 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 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.
project là không được đụng.
"""
thu_muc = self._thu_muc_cua(project_id)
if project_id == self._workspace_project and thu_muc == self._workspace_dir:
if project_id == self._workspace_project:
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()
@@ -79,32 +79,6 @@ class ProjectFolderRuleMixin:
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.
+1 -8
View File
@@ -278,17 +278,10 @@ class AnthropicProvider(Provider):
raise ProviderError(f"Anthropic: {evt.get('error', {}).get('message', 'error')}")
resp.close()
break # stream finished normally (or cancelled)
except Exception as exc: # noqa: BLE001 — lọc lại ngay bên dưới
except requests.RequestException as exc:
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")
+2 -8
View File
@@ -254,19 +254,13 @@ class OpenAICompatProvider(Provider):
slot["args"] += fn["arguments"]
resp.close()
break # stream finished normally (or cancelled)
except Exception as exc: # noqa: BLE001 — lọc lại ngay bên dưới
except requests.RequestException as exc:
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 cancel was requested, close cleanly without retry
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:
-116
View File
@@ -1,116 +0,0 @@
"""Tiêu đề hội thoại không được cắt vào giữa một từ.
Triệu chứng người dùng báo: thanh tiêu đề màn Cowork hiện
Đọc các tệp trong thư mục của project này và tóm tắt từng tệ…
Câu gốc dài 61 ký tự, mốc cắt cứng ở 60 rơi đúng vào giữa chữ "tệp" và bỏ mất
đúng một chữ cái. Người đọc thấy "tệ…" chứ không thấy "tệp", nên nó đọc ra như
lỗi gõ chứ không như một câu bị rút gọn.
``shorten_title`` viết nốt từ đang dở thay vì cắt ngang nó, và chỉ thêm dấu ba
chấm khi thật sự có chữ bị bỏ đi.
"""
from __future__ import annotations
import pytest
from cowork_local.core.history import (
TITLE_MAX_CHARS, _TITLE_SLACK, derive_title, shorten_title,
)
#: Đúng câu trong ảnh người dùng gửi — 61 ký tự, vượt giới hạn đúng 1.
CAU_TRONG_ANH = "Đọc các tệp trong thư mục của project này và tóm tắt từng tệp"
def test_dung_ca_nguoi_dung_bao():
"""Bài đỏ trước khi sửa: cắt cứng cho ra "…từng tệ…"."""
assert len(CAU_TRONG_ANH) == TITLE_MAX_CHARS + 1
ket_qua = shorten_title(CAU_TRONG_ANH)
assert ket_qua.endswith("tệp"), ket_qua
assert "tệ…" not in ket_qua
# Không chữ nào bị bỏ thì không được thêm dấu ba chấm — dấu đó là nói dối.
assert ket_qua == CAU_TRONG_ANH
def test_ngan_hon_gioi_han_thi_giu_nguyen():
assert shorten_title("Tiêu đề ngắn") == "Tiêu đề ngắn"
def test_dung_bang_gioi_han_thi_giu_nguyen():
text = "x" * TITLE_MAX_CHARS
assert shorten_title(text) == text
def test_moc_cat_roi_dung_giua_hai_tu_thi_cat_ngay_do():
text = "x" * TITLE_MAX_CHARS + " còn nữa"
assert shorten_title(text) == "x" * TITLE_MAX_CHARS + "…"
def test_viet_not_tu_roi_van_con_chu_phia_sau_thi_co_ba_cham():
text = "x" * 57 + " abcdefgh ijk"
ket_qua = shorten_title(text)
assert ket_qua == "x" * 57 + " abcdefgh…"
def test_tu_dai_bat_thuong_thi_lui_ve_ranh_gioi_truoc():
"""Một đường dẫn hay URL dài không được kéo tiêu đề dài ra tuỳ ý."""
text = "x" * 57 + " " + "y" * 40 + " z"
ket_qua = shorten_title(text)
assert ket_qua == "x" * 57 + "…"
assert len(ket_qua) <= TITLE_MAX_CHARS + 1
def test_ca_tieu_de_chi_la_mot_tu_dai_thi_danh_cat_cung():
"""Không còn ranh giới từ nào để bám — cắt cứng là lối ra duy nhất."""
text = "y" * 100
assert shorten_title(text) == "y" * TITLE_MAX_CHARS + "…"
def test_khong_bao_gio_vuot_qua_gioi_han_cong_slack():
text = "x" * 55 + " " + "y" * 11 + " phần đuôi còn dài nữa"
assert len(shorten_title(text)) <= TITLE_MAX_CHARS + _TITLE_SLACK + 1
@pytest.mark.parametrize("text", [
CAU_TRONG_ANH,
"Phân tích bảng tính doanh thu quý bốn và lập báo cáo tổng hợp cho ban giám đốc",
"Tóm tắt toàn bộ tài liệu kỹ thuật trong thư mục rồi xuất ra một tệp markdown",
"a bb ccc dddd eeeee ffffff ggggggg hhhhhhhh iiiiiiiii jjjjjjjjjj kkkkkkkkkkk",
])
def test_ket_qua_luon_ket_thuc_o_ranh_gioi_tu(text):
"""Bất biến của cả hàm: phần chữ giữ lại phải là một tiền tố kết thúc đúng
chỗ một từ kết thúc trong câu gốc — không bao giờ là nửa từ."""
ket_qua = shorten_title(text)
giu_lai = ket_qua[:-1] if ket_qua.endswith("…") else ket_qua
assert text.startswith(giu_lai), "kết quả không còn là tiền tố của câu gốc"
assert len(giu_lai) == len(text) or text[len(giu_lai)].isspace(), (
f"cắt vào giữa từ: ...{giu_lai[-12:]!r} | còn lại {text[len(giu_lai):][:8]!r}"
)
def test_derive_title_dung_cung_mot_luat():
"""Tiêu đề lưu vào lịch sử và tiêu đề trên thanh tiêu đề phải khớp nhau."""
messages = [{"role": "user", "content": CAU_TRONG_ANH}]
assert derive_title(messages) == shorten_title(CAU_TRONG_ANH)
def test_derive_title_van_gom_khoang_trang_thua():
"""Hành vi cũ phải giữ: xuống dòng và khoảng trắng thừa gộp về một dấu cách."""
tin_nhan = """ Dòng một
Dòng hai """
messages = [{"role": "user", "content": tin_nhan}]
assert derive_title(messages) == "Dòng một Dòng hai"
@@ -1,157 +0,0 @@
"""Đổi thư mục làm việc thì màn GraphRAG phải trỏ theo thư mục mới.
Triệu chứng: ở tab Project bấm "Đổi" sang một đường dẫn khác — ô Thư mục làm
việc cập nhật ngay, tên project vẫn đúng, nên nhìn qua tưởng xong. Nhưng màn
GraphRAG vẫn giữ đường dẫn cũ và quét nhầm thư mục.
Nguyên nhân có hai mảnh, thiếu mảnh nào cũng vẫn hỏng:
* ``_pick_folder`` ghi ``output_dir`` rồi dừng — đổ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.
* Kể cả có chạy lại, ``StructureGraphView.set_workspace_project`` ngày trước
chốt theo MỖI project id, nên cùng một project là nó thoát ra ngay. Tab Thư
mục vốn đã chốt theo ĐƯỜNG DẪN — hai nơi tưởng cùng luật mà thật ra không.
Giống ``test_graphrag_project_persists.py``: KHÔNG dựng renderer thật, vì nó
kéo theo QtWebEngine — dựng nó trong bộ ``tests/ui`` làm cả bộ chết giữa chừng.
Thứ cần chốt ở đây là luồng điều khiển, không phải phần vẽ.
"""
from __future__ import annotations
import pytest
pytest.importorskip("PySide6", reason="cần PySide6 để dựng widget thật")
from cowork_local.core import projects as projects_mod
from cowork_local.core.projects import Project
class _RendererGhi:
"""Thay GraphRenderer — chỉ ghi lại nó bị áp project mấy lần."""
def __init__(self):
self.lan_ap = []
def set_project(self, project_id):
self.lan_ap.append(project_id)
@pytest.fixture
def view(qapp):
"""``StructureGraphView`` với renderer bị thay, dựng qua ``__new__``."""
from cowork_local.presentation.graph.structure_graph_view import StructureGraphView
v = StructureGraphView.__new__(StructureGraphView)
v._workspace_project = None
v._workspace_dir = None
v.renderer = _RendererGhi()
return v
def _kho_mot_project(monkeypatch, thu_muc) -> Project:
"""Kho project giả gồm đúng một project trỏ vào ``thu_muc``."""
du_an = Project(project_id="p1", name="Mynt4Project1", output_dir=str(thu_muc))
monkeypatch.setattr(projects_mod, "list_projects", lambda directory=None: [du_an])
monkeypatch.setattr(projects_mod, "load_project",
lambda pid, directory=None: du_an if pid == "p1" else None)
monkeypatch.setattr(projects_mod, "save_project", lambda p, directory=None: None)
return du_an
# ---- mảnh 1: chốt của GraphRAG phải nhìn cả đường dẫn -------------------
def test_cung_project_nhung_thu_muc_moi_thi_ap_lai(view, monkeypatch, tmp_path):
"""Đây là chỗ chốt cũ bỏ lọt: id giống nhau nhưng đường dẫn đã khác."""
du_an = _kho_mot_project(monkeypatch, tmp_path / "cu")
view.set_workspace_project("p1")
assert view.renderer.lan_ap == ["p1"]
du_an.output_dir = str(tmp_path / "moi")
view.set_workspace_project("p1")
assert view.renderer.lan_ap == ["p1", "p1"], (
"đổi thư mục xong mà GraphRAG không được áp lại — sẽ quét nhầm chỗ")
def test_cung_project_cung_thu_muc_thi_khong_ap_lai(view, monkeypatch, tmp_path):
"""Chốt cũ phải giữ nguyên: mỗi lần vào lại màn Workspace,
``_bind_project`` chạy lại — áp vô điều kiện là kéo bộ chọn project của
chính màn GraphRAG về theo, chọn xong chuyển tab là mất."""
_kho_mot_project(monkeypatch, tmp_path / "yen")
view.set_workspace_project("p1")
view.set_workspace_project("p1")
view.set_workspace_project("p1")
assert view.renderer.lan_ap == ["p1"]
def test_doi_sang_project_khac_van_ap(view, monkeypatch, tmp_path):
"""Hành vi vốn có: đổi sang project khác thì vẫn phải áp."""
_kho_mot_project(monkeypatch, tmp_path / "a")
view.set_workspace_project("p1")
view.set_workspace_project("p2")
assert view.renderer.lan_ap == ["p1", "p2"]
# ---- mảnh 2: đổi thư mục phải kích hoạt việc trỏ lại --------------------
class _StructureGhi:
"""Thay cả màn GraphRAG — ghi lại nó được trỏ lại vào project nào."""
def __init__(self):
self.lan_tro = []
def set_workspace_project(self, project_id):
self.lan_tro.append(project_id)
class _FolderGhi:
"""Thay tab Thư mục — ghi lại nó được trỏ vào đường dẫn nào."""
def __init__(self):
self.lan_tro = []
def set_project_root(self, path):
self.lan_tro.append(path)
@pytest.fixture(scope="module")
def ws(qapp, tmp_path_factory):
"""Màn Workspace thật, nhưng KHÔNG mở tab GraphRAG (xem docstring đầu file)."""
from cowork_local.presentation.shell.bootstrap import build_config, build_context
from cowork_local.presentation.shell.main_window import MainWindow
config_path = tmp_path_factory.mktemp("cfg") / "config.json"
build_config(config_path)
window = MainWindow(build_context(config_path))
yield window.workspace
window.close()
def test_bam_doi_thu_muc_thi_graphrag_duoc_tro_lai(qapp, ws, monkeypatch, tmp_path):
"""Đúng thao tác người dùng báo: bấm "Đổi" ở tab Project."""
from PySide6.QtWidgets import QFileDialog
from cowork_local.ui import workspace_tab as wt
cu, moi = tmp_path / "thu-muc-cu", tmp_path / "thu-muc-moi"
cu.mkdir()
moi.mkdir()
du_an = _kho_mot_project(monkeypatch, cu)
structure, folder = _StructureGhi(), _FolderGhi()
monkeypatch.setattr(ws, "_structure", structure, raising=False)
monkeypatch.setattr(ws, "_folder", folder, raising=False)
ws._current_id = "p1"
monkeypatch.setattr(QFileDialog, "getExistingDirectory",
staticmethod(lambda *a, **k: str(moi)))
wt.WorkspaceTab._pick_folder(ws)
assert du_an.output_dir == str(moi), "chưa ghi thư mục mới"
assert ws.folder_lbl.text() == str(moi)
assert structure.lan_tro == ["p1"], (
"GraphRAG không được trỏ lại — sẽ giữ đường dẫn cũ và quét nhầm")
assert folder.lan_tro == [str(moi)], "tab Thư mục cũng phải trỏ theo"
@@ -31,7 +31,6 @@ def view(qapp, monkeypatch):
v = StructureGraphView.__new__(StructureGraphView)
v._workspace_project = None
v._workspace_dir = None # chốt còn theo cả đường dẫn, không chỉ id
v.renderer = _Ghi()
return v
@@ -1,167 +0,0 @@
"""Đổi thư mục project thì đồ thị giữa màn GraphRAG phải quét lại.
Nối tiếp ``test_graphrag_follows_folder_change.py``. Sau khi đường dẫn trên
thanh đã trỏ đúng thư mục mới, các node ở giữa màn vẫn là của thư mục cũ: không
có lệnh quét lại nào được phát ra.
Nguyên nhân cùng một họ với hai mảnh trước — câu hỏi "có gì đổi không" được trả
lời bằng project id chứ không bằng thứ thật sự quyết định kết quả quét:
project_changed = pid != self._active_project_id
Đổi thư mục làm việc ở màn Project giữ nguyên id, nên ``project_changed`` là
False và cả khối phát tín hiệu lẫn khối gọi ``_scan()`` đều bị bỏ qua.
Cố ý KHÔNG dựng ``GraphRenderer`` thật: nó kéo theo QtWebEngine, dựng trong bộ
``tests/ui`` làm cả bộ chết giữa chừng (xem docstring của
``test_graphrag_follows_folder_change.py``). ``_on_project_changed`` là Python
thuần trên các thuộc tính của chính nó, nên gọi thẳng với một ``self`` giả là đủ
và đúng hơn — bài test chốt luồng quyết định, không chốt phần vẽ.
"""
from __future__ import annotations
import pytest
pytest.importorskip("PySide6", reason="cần PySide6 để nạp module renderer")
from cowork_local.core import projects as projects_mod
from cowork_local.core.projects import Project
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
class _O:
"""Vật thể ghi lại lời gọi, thay cho một widget Qt."""
def __init__(self, **thuoc_tinh):
self.__dict__.update(thuoc_tinh)
self.da_goi = []
def __getattr__(self, ten):
def ghi(*args, **kwargs):
self.da_goi.append((ten, args))
return ghi
class _ComboGia:
def __init__(self, pid):
self._pid = pid
def currentData(self):
return self._pid
class _OGia:
"""Ô nhập đường dẫn: giữ được chữ, và ghi lại việc bị khoá."""
def __init__(self, text=""):
self._text = text
self.read_only = False
def text(self):
return self._text
def setText(self, value):
self._text = value
def setReadOnly(self, value):
self.read_only = value
class _Renderer:
"""``self`` giả cho ``GraphRenderer._on_project_changed``."""
def __init__(self, pid, active_id="", active_path="", hien=True):
self.project_combo = _ComboGia(pid)
self.path_edit = _OGia()
self._pick_btn = _O()
self.project_changed = _O()
self._active_project_id = active_id
self._active_path = active_path
self._needs_scan = False
self._hien = hien
self.lan_quet = 0
def isVisible(self):
return self._hien
def _scan(self):
self.lan_quet += 1
# -- tiện cho khẳng định --------------------------------------------- #
@property
def so_lan_bao_doi(self):
"""Số lần phát tín hiệu "đã đổi mục tiêu"."""
return sum(1 for ten, _ in self.project_changed.da_goi if ten == "emit")
@pytest.fixture
def du_an(monkeypatch, tmp_path):
"""Một project duy nhất trong kho giả, trỏ vào ``tmp_path/cu``."""
p = Project(project_id="p1", name="test", output_dir=str(tmp_path / "cu"))
monkeypatch.setattr(projects_mod, "load_project",
lambda pid, directory=None: p if pid == "p1" else None)
return p
def _chay(renderer):
"""Gọi đúng hàm thật với ``self`` giả."""
GraphRenderer._on_project_changed(renderer, 0)
def test_cung_project_thu_muc_moi_thi_quet_lai(du_an, tmp_path):
"""Đây là chỗ hỏng người dùng báo: đường dẫn đổi mà node giữa màn thì không."""
r = _Renderer("p1")
_chay(r) # lần đầu: khoá vào project
assert r.lan_quet == 1
du_an.output_dir = str(tmp_path / "moi") # người dùng bấm "Đổi" ở tab Project
_chay(r)
assert r.path_edit.text() == str(tmp_path / "moi")
assert r.lan_quet == 2, "đổi thư mục xong nhưng không quét lại — node vẫn của thư mục cũ"
assert r.so_lan_bao_doi == 2, (
"phải báo đổi để khung hỏi-đáp bỏ phần trích xuất của thư mục cũ")
def test_cung_project_cung_thu_muc_thi_khong_quet_lai(du_an):
"""Bảo vệ sẵn có: ``_bind_project`` chạy lại mỗi lần vào lại màn Workspace,
quét lại vô cớ là vừa giật vừa tốn."""
r = _Renderer("p1")
_chay(r)
assert r.lan_quet == 1
_chay(r)
_chay(r)
assert r.lan_quet == 1
def test_doi_sang_project_khac_van_quet_lai(du_an, tmp_path, monkeypatch):
"""Hành vi vốn có, không được mất."""
khac = Project(project_id="p2", name="khac", output_dir=str(tmp_path / "cua-p2"))
monkeypatch.setattr(projects_mod, "load_project",
lambda pid, directory=None: du_an if pid == "p1" else khac)
r = _Renderer("p1")
_chay(r)
r.project_combo._pid = "p2"
_chay(r)
assert r.lan_quet == 2
assert r.path_edit.text() == str(tmp_path / "cua-p2")
def test_man_dang_an_thi_hoan_quet_chu_khong_quet_ngay(du_an, tmp_path):
"""Đổi thư mục từ màn Project trong khi GraphRAG đang ẩn: đánh dấu để quét
ở lần vào sau, đúng luật hoãn mà ``auto_scan_and_fit`` dựa vào."""
r = _Renderer("p1", hien=False)
_chay(r)
# Lần khoá đầu tiên đã đặt cờ rồi; xoá đi để bài này thật sự kiểm được
# lần ĐỔI THƯ MỤC, chứ không xanh nhờ cờ còn sót của lần trước.
r._needs_scan = False
du_an.output_dir = str(tmp_path / "moi")
_chay(r)
assert r.lan_quet == 0
assert r._needs_scan is True
-273
View File
@@ -1,273 +0,0 @@
"""Bấm "Dừng" phải thấy được là nó đã ăn.
Triệu chứng người dùng báo: một lượt chạy đã 159 giây, bấm Dừng, rồi không có
gì đổi trên màn hình — chỉ báo vẫn đếm "Đang chạy · 160s", nên không biết nút
có tác dụng hay không.
Hai chỗ nói sai, cả hai đều kiểm được:
* Lúc bấm — ``stop()`` chỉ đặt cờ huỷ trên worker rồi thôi. 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 khoảng đó
màn hình vẫn nói "Đang chạy".
* Lúc kết thúc — ``_on_finished`` luôn đặt dấu XANH "Đã hoàn thành", kể cả khi
lượt chạy vừa bị người dùng dừng. Dấu đó nói ngược hẳn với thứ vừa xảy ra.
Gọi thẳng phương thức với một ``self`` giả, không dựng ``ChatPanel`` thật: những
hàm này là Python thuần trên vài thuộc tính của chính nó, và dựng cả màn chat
trong ``tests/ui`` kéo theo QtWebEngine (xem
``test_graphrag_follows_folder_change.py``).
"""
from __future__ import annotations
import pytest
pytest.importorskip("PySide6", reason="cần PySide6 để nạp module")
from cowork_local.presentation.chat.chat_panel import ChatPanel
from cowork_local.presentation.chat.chat_turn_runner import ChatTurnRunnerMixin
class _ChiBaoGia:
"""Thay ``ThinkingIndicator`` — ghi lại nhãn nó được yêu cầu hiện."""
def __init__(self):
self.nhan = []
def set_label(self, key):
self.nhan.append(key)
def start(self, key="chat.running"):
self.nhan.append(key)
def stop(self):
self.nhan.append(None)
class _KhungChatGia:
"""Thay ``chat_view`` — ghi lại loại dấu kết thúc được đặt vào."""
def __init__(self):
self.dau = []
def add_error(self, text):
self.dau.append(("error", text))
return object()
def add_status(self, text):
self.dau.append(("status", text))
return object()
def add_success(self, text):
self.dau.append(("success", text))
return object()
class _WorkerGia:
def __init__(self, dang_chay=True):
self._dang_chay = dang_chay
self.da_yeu_cau_dung = False
def isRunning(self):
return self._dang_chay
def request_stop(self):
self.da_yeu_cau_dung = True
class _ComposerGia:
def __init__(self):
self.da_xoa_hang_doi = False
def clear_queue(self):
self.da_xoa_hang_doi = True
def set_running(self, _v):
pass
def set_busy(self, _v):
pass
def set_text(self, _v):
pass
class _TinNhanGia:
def __init__(self):
self.da_phat = []
def emit(self, *args):
self.da_phat.append(args[0] if len(args) == 1 else args)
class _Panel:
"""``self`` giả cho các phương thức đang kiểm."""
kind = "cowork"
def __init__(self, dang_chay=True):
self.worker = _WorkerGia(dang_chay)
self._active = {self.worker: {}} if dang_chay else {}
self.composer = _ComposerGia()
self.status_message = _TinNhanGia()
self.turn_finished = _TinNhanGia()
self.graph_event = _TinNhanGia()
self.session_name = "test"
self.thinking = _ChiBaoGia()
self.chat_view = _KhungChatGia()
self._stop_requested = False
self._ban_ron = dang_chay
def _view_busy(self):
return self._ban_ron
def _max_parallel(self):
return 2
# -- những thứ _on_failed cần, đều là no-op ------------------------- #
def _turn_is_live(self, _ctx):
return True
def _end_turn(self, _ctx):
pass
def _cleanup_turn(self, _ctx, _ok):
pass
def _drain_queue(self):
pass
def _persist_session(self, _ctx):
pass
def _dau_ket_thuc(self):
"""Gọi phương thức THẬT — đây là thứ đang được kiểm, không giả lập.
Tra tên lúc GỌI chứ không lúc dựng lớp: tra lúc dựng thì trên bản
chưa sửa cả file test đổ ngay ở khâu thu thập, và một lỗi thu thập
không nói được gì về hành vi.
"""
return ChatTurnRunnerMixin._dau_ket_thuc(self)
# ---- lúc bấm Dừng -------------------------------------------------------
def test_bam_dung_thi_chi_bao_doi_sang_dang_dung():
"""Đây là chỗ hỏng người dùng thấy: bấm xong màn hình không đổi gì."""
p = _Panel()
ChatTurnRunnerMixin.stop(p)
assert p._stop_requested is True
assert "chat.stopping" in p.thinking.nhan, (
"chỉ báo vẫn nói 'Đang chạy' — người dùng đọc ra là nút Dừng không ăn")
def test_bam_dung_van_yeu_cau_worker_dung_va_xoa_hang_doi():
"""Hành vi vốn có, không được mất khi thêm phần hiển thị."""
p = _Panel()
ChatTurnRunnerMixin.stop(p)
assert p.worker.da_yeu_cau_dung is True
assert p.composer.da_xoa_hang_doi is True
def test_khong_co_luot_nao_chay_thi_bam_dung_khong_lam_gi():
"""Không có gì để dừng thì đừng nói dối là đang dừng."""
p = _Panel(dang_chay=False)
ChatTurnRunnerMixin.stop(p)
assert p._stop_requested is False
assert p.thinking.nhan == []
# ---- lúc lượt chạy kết thúc --------------------------------------------
def test_da_dung_thi_dat_dau_da_dung_chu_khong_phai_hoan_thanh():
p = _Panel()
p._stop_requested = True
ChatTurnRunnerMixin._dau_ket_thuc(p)
loai, text = p.chat_view.dau[0]
assert loai == "status", "dừng theo yêu cầu mà vẫn đặt dấu xanh 'Đã hoàn thành'"
assert "dừng" in text.lower() or "stop" in text.lower(), text
def test_ket_thuc_binh_thuong_van_dat_dau_hoan_thanh():
"""Chặn một chiều là hỏng tính năng — lượt chạy xong xuôi vẫn phải xanh."""
p = _Panel()
ChatTurnRunnerMixin._dau_ket_thuc(p)
assert p.chat_view.dau[0][0] == "success"
# ---- đồng bộ lại chỉ báo (đổi tab, Refresh History) --------------------
def test_dong_bo_lai_khong_keo_nhan_ve_dang_chay():
"""``_sync_indicators`` chạy lại khi chuyển tab; nó mà đặt lại
"chat.running" là nhãn "đang dừng" bị xoá ngay sau khi bấm."""
p = _Panel()
p._stop_requested = True
ChatPanel._sync_indicators(p)
assert p.thinking.nhan[-1] == "chat.stopping"
def test_dong_bo_lai_khi_chua_bam_dung_thi_van_la_dang_chay():
p = _Panel()
ChatPanel._sync_indicators(p)
assert p.thinking.nhan[-1] == "chat.running"
# ---- lượt chạy kết thúc bằng NGOẠI LỆ vì vừa bị huỷ --------------------
def test_da_bam_dung_thi_loi_luc_huy_hien_ra_la_da_dung():
"""Đúng thứ người dùng chụp lại: bấm Dừng xong nhận một dòng đỏ
"'NoneType' object has no attribute 'read'" — đó là hệ quả của chính việc
huỷ (đóng socket giữa stream), không phải một lỗi cần báo."""
p = _Panel()
p._stop_requested = True
ChatTurnRunnerMixin._on_failed(p, {}, "'NoneType' object has no attribute 'read'")
loai = [l for l, _ in p.chat_view.dau]
assert "error" not in loai, "vẫn dội lỗi Python ra màn hình sau khi người dùng bấm Dừng"
assert loai == ["status"]
assert "dừng" in p.chat_view.dau[0][1].lower()
def test_loi_that_khi_chua_bam_dung_van_bao_loi_nhu_cu():
"""Chặn một chiều là nuốt mất lỗi thật."""
p = _Panel()
ChatTurnRunnerMixin._on_failed(p, {}, "gateway 500")
assert p.chat_view.dau == [("error", "gateway 500")]
def test_thanh_trang_thai_noi_cung_mot_chuyen_voi_khung_chat():
p = _Panel()
p._stop_requested = True
ChatTurnRunnerMixin._on_failed(p, {}, "bat ky loi gi")
assert any("dừng" in t.lower() for t in p.status_message.da_phat), p.status_message.da_phat
# ---- i18n ---------------------------------------------------------------
@pytest.mark.parametrize("key", ["chat.stopping", "chat.stopped_marker",
"chatpanel.stopped"])
def test_key_moi_co_du_ba_ngon_ngu(key):
from cowork_local import i18n
entry = i18n.STRINGS[key]
for lang in ("en", "ja", "vi"):
assert entry.get(lang), f"{key} thiếu {lang}"
-2
View File
@@ -947,7 +947,6 @@ class WorkspaceTab(ProjectEditingMixin, ProjectFolderRuleMixin, QWidget):
project.output_dir = chosen
save_project(project)
self.folder_lbl.setText(chosen)
self.rebind_workspace_folder()
self.status_message.emit(tr("workspace.saved", name=project.name))
def _open_workspace(self) -> None:
@@ -1003,7 +1002,6 @@ class WorkspaceTab(ProjectEditingMixin, ProjectFolderRuleMixin, QWidget):
project.cloud_source = cloud_source
save_project(project)
self.folder_lbl.setText(str(local_dir))
self.rebind_workspace_folder()
self._refresh_cloud_badge(project)
self.projects_changed.emit()
if report.errors: