Compare commits

..
Author SHA1 Message Date
duylh19andClaude Opus 5 ddb31f9aff fix(chat): bấm Dừng thì báo rõ là đã dừng, không dội lỗi Python
Một lượt chạy đã 159 giây, bấm Dừng, rồi hoặc không thấy gì đổi, hoặc nhận một
dòng đỏ "'NoneType' object has no attribute 'read'". Cả hai đều không trả lời
được câu người dùng đang hỏi: nó dừng chưa?

Ba chỗ nói sai:

- stop() chỉ đặt cờ huỷ rồi thôi. Huỷ thật mất vài giây (đang chờ gateway, đang
  chạy dở một tool), mà trong khoảng đó chỉ báo vẫn đếm "Đang chạy · 160s".
  Nay đổi nhãn sang "Đang dừng" ngay lúc bấm, và _sync_indicators giữ nhãn đó
  khi chuyển tab.
- Huỷ giữa stream đóng socket, urllib3 ném AttributeError chứ không phải
  requests.RequestException, nên hai vòng đọc stream của provider bắt hụt và
  lỗi chui lên tận giao diện. Nay bắt rộng rồi lọc lại: chỉ nuốt khi thật sự
  đang huỷ, lỗi khác vẫn ném tiếp nguyên vẹn.
- Lượt chạy kết thúc sau khi huỷ vẫn đặt dấu XANH "Đã hoàn thành" (hoặc bong
  bóng lỗi đỏ nếu kết thúc bằng ngoại lệ). Nay đặt "⏹ Đã dừng theo yêu cầu", và
  thanh trạng thái nói cùng một chuyện.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 08:39:43 +09:00
duylh19andClaude Opus 5 b500b3e57d fix(graphrag): đổi thư mục project thì quét lại đồ thị theo thư mục mới
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.

Cùng một họ sai lầm với hai mảnh trước — câu hỏi "có gì đổi không" trả lời bằng
project id chứ không bằng thứ quyết định kết quả quét:

    project_changed = pid != self._active_project_id

Đổi thư mục 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, trong khi dòng đặt path_edit lại nằm
ngoài khối đó — thanh địa chỉ đúng mà đồ thị đứng yên.

Tách khối "project sandbox lock" sang graph_project_lock.py: graph_renderer.py
đang ở 399/400 dòng, đúng một dòng trước trần của scripts/check_loc.py, và cổng
đó nói rõ cách duy nhất đúng khi chạm trần là tách file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 08:39:22 +09:00
duylh19andClaude Opus 5 76225aa118 fix(workspace): đổi thư mục project thì GraphRAG trỏ theo thư mục mới
Ở tab Project bấm "Đổi" sang đườ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 giữ nguyên
đườ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 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 — comment ở đó tự nhận hai nơi cùng luật, thật ra thì không.

Thêm ProjectFolderRuleMixin.rebind_workspace_folder để trỏ lại những màn đang
bám vào thư mục, gọi từ cả hai đường đổi thư mục ở màn Project. Cố ý không gọi
_load_current: hàm đó nạp lại cả biểu mẫu từ đĩa, gọi lúc đang sửa dở Tên/Mô tả
là xoá mất phần chưa lưu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 08:39:21 +09:00
duylh19andClaude Opus 5 35334274eb fix(chat): rút gọn tiêu đề hội thoại không cắt vào giữa từ
Thanh tiêu đề màn Cowork hiện "…tóm tắt từng tệ…" cho một câu dài 61 ký tự:
mốc cắt cứng ở 60 rơi đúng giữa chữ "tệp" và bỏ mất một chữ cái, đọc ra như lỗi
gõ chứ không như một câu bị rút gọn. Dấu ba chấm ở đó còn nói dối — nó báo còn
nhiều chữ nữa trong khi chỉ thiếu đúng một ký tự.

Thêm core.history.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ỏ. Từ dài bất thường (đường dẫn,
URL) thì lùi về ranh giới từ trước đó để một token dài không kéo tiêu đề dài ra
tuỳ ý.

Dùng chung cho cả hai nơi sinh tiêu đề (derive_title và ChatTurnRunnerMixin) để
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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 08:38:59 +09:00
minhanhpkproandClaude Opus 5 10b8379824 fix(settings): giữ tên nút chặn mạng cũ và nới cột mục lục
CI / test (pull_request) Canceled after 0s
- Trả nhãn công tắc về "Block network for agent-run commands"
  (vi/ja như cũ).
- Cột mục lục bên trái tính độ rộng theo kiểu chữ của mục đang chọn
  (in đậm + lề 10px), trước đây "Sandbox Security Layer" bị cắt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 00:19:26 +09:00
minhanhpkproandClaude Opus 5 8c497cf50a fix(folder): xem trước HTML hiện ảnh web và không làm văng app
CI / test (pull_request) Canceled after 0s
- Trang file:// cần LocalContentCanAccessRemoteUrls mới tải được ảnh,
  CSS, JS trên web; khi bật "Chặn mạng" bộ chặn request vẫn chặn.
- Dùng một QWebEngineProfile chung thuộc QApplication: profile do view
  sở hữu bị huỷ trước trang, Qt báo "Release of profile requested but
  WebEnginePage still not deleted" và app văng (0xc0000409 trong
  Qt6Core.dll) khi chuyển tab Graph sang Folder.
- Giữ tham chiếu Python tới bộ chặn request để nó không bị thu gom.
- Thêm CLAUDE.md hướng dẫn làm việc trong repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:09:49 +09:00
minhanhpkproandClaude Opus 5 b78d48320c fix(sandbox): "Chặn mạng" chặn mọi đường ra mạng, trừ nhà cung cấp AI
Trước đây công tắc chỉ chặn tool mạng của agent; lệnh shell chỉ bị proxy
giả, còn M365, Teams, nút Test, MCP đang chạy, task script, link đính kèm
task, pip tự cài và tài nguyên web trong xem trước HTML vẫn ra mạng tự do.

- Cổng chung application/network/network_guard.py, nối vào cấu hình sống
  ở Composition Root; nhà cung cấp AI (chat, danh sách model, thử model)
  không đi qua cổng này.
- Lệnh shell của agent và task script chạy trong Windows AppContainer
  không có quyền mạng (macOS: sandbox-exec, Linux: unshare --net);
  không cô lập được thì từ chối chạy.
- Không cấp quyền kế thừa của AppContainer lên thư mục chứa PySide6:
  Chromium không nạp được Qt6WebEngineCore.dll và tab Graph bị hỏng.
- Bật chặn thì dừng MCP đang chạy; tool OneDrive đồng bộ trên máy vẫn dùng.
- Mặc định tắt khi mở app lần đầu; nhãn và tooltip 3 ngôn ngữ cập nhật.
- Test: tests/test_network_guard_lanes.py (có bài AppContainer thật).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:32:44 +09:00
duylh19andClaude Opus 5 b7a41b3658 feat(workspace): mỗi thư mục làm việc chỉ thuộc về một project
CI / test (pull_request) Canceled after 0s
Thư mục làm việc vừa là sandbox (agent chỉ đọc/ghi bên trong nó) vừa là kho
kiến thức chung của project (file ở gốc được mọi đoạn chat tự đọc). Hai project
trỏ vào cùng một thư mục là đọc lẫn dữ liệu của nhau và ghi đè lên nhau — đúng
điều docstring đầu core/projects.py nói sandbox sinh ra để ngăn, nhưng không có
chỗ nào thực thi: cả ba nơi đặt thư mục đều gán thẳng output_dir rồi lưu.

Thêm core.projects.folder_conflict: so theo workspace_dir() chứ không 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),
chuẩn hoá đường dẫn bằng expanduser + abspath + normcase, và coi thư mục lồng
nhau là trùng — đứng ở thư mục cha vẫn với tới được file của project con.

Chặn tại cả ba nơi ghi output_dir: nút Đổi ở màn Project, thư mục mirror cloud,
và nút chọn thư mục trong tab Cowork. Chặn lúc CHỌN chứ không lúc Lưu, vì nút
Lưu 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.

Dữ liệu đã trùng sẵn không bị tự sửa: có nhãn cảnh báo ngay dưới ô Thư mục làm
việc, nói rõ trùng với project nào, để người dùng tự quyết.

Luật ở module riêng (presentation/workspace/project_folder_rules.py) vì nhét
vào project_editing.py sẽ đẩy file đó lên 435 dòng, vượt trần 400 của
scripts/check_loc.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:36:16 +09:00
duylh19andClaude Opus 5 bbdf146d2c fix(workspace): bật nút Sửa project khi đã có project đang mở
Vào sub-tab Project khi đang mở một project thì biểu mẫu hiện đủ dữ liệu nhưng
nút "Sửa project" vẫn mờ, không bấm được.

Trạng thái bật/mờ của ba nút chỉ được tính trong set_project_editable, mà việc
đổi project không đi qua hàm đó — _load_current chỉ nạp lại biểu mẫu.
_sync_project_buttons có chạy khi đổi sub-tab nhưng chỉ chỉnh ẩn/hiện, nên nút
hiện ra mang theo trạng thái mờ tính từ lúc dựng cửa sổ, khi chưa project nào
được chọn.

Dồn phép tính về _sync_project_buttons — nơi duy nhất biết đủ cả ba yếu tố — và
cho nó chạy thêm khi project_selected phát tín hiệu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:35:40 +09:00
duylh19andClaude Opus 5 35f24e0e28 fix(rail): canh hàng Cài đặt thẳng với Dashboard và Giám sát
Hàng "Cài đặt" là một QPushButton tự dựng icon + chữ chứ không phải hàng của
QTreeWidget như Dashboard/Giám sát, nên khoảng cách icon-chữ do layout đặt chứ
không do delegate của cây. Đặt _NAV_ROW_GAP + 4 (=10) ở đó, cộng 10px lề trái
và hộp icon 22px, đưa chữ ra x=42 trong khi delegate đặt chữ ở x=35 — thụt phải
7px, thấy rõ bằng mắt.

Lấy khe đó từ style (PM_FocusFrameHMargin + 1) thay vì tự đặt số, đúng khe mà
delegate dùng. Kèm test hồi quy đo bằng pixel trên cửa sổ thật, ép ba hàng về
cùng icon và cùng chữ để đo bố cục chứ không đo hình dáng glyph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:34:57 +09:00
thanhnv 2759ed94ba perf: avoid refreshing workspace on cowork tab switch
CI / test (pull_request) Canceled after 0s
2026-09-16 01:05:52 +09:00
thanhnv 8548c1e923 fix: enforce sandbox network block for agent tools 2026-09-16 01:00:31 +09:00
thanhnv caf3b74931 fix: use native GraphRAG renderer on macOS 2026-09-16 00:38:56 +09:00
thanhnv c00b83cd1a fix: add spacing to project row metadata 2026-09-16 00:31:33 +09:00
thanhnv a04f8a928d fix: hide cloud workspace picker 2026-09-16 00:30:07 +09:00
thanhnv cc8d5c8c0a fix: keep navigation row spacing uniform 2026-09-16 00:28:32 +09:00
thanhnv 2e3e719259 fix: separate settings from navigation group 2026-09-16 00:25:37 +09:00
thanhnv c699beb6fd fix: align settings navigation item 2026-09-16 00:23:03 +09:00
thanhnv 7607f44030 chore: checkpoint current performance and UI changes 2026-09-16 00:05:34 +09:00
17 changed files with 959 additions and 70 deletions
+41 -2
View File
@@ -12,6 +12,7 @@ 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
@@ -33,6 +34,45 @@ 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.
@@ -40,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)"
+2
View File
@@ -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.",
+5
View File
@@ -229,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"},
+7 -1
View File
@@ -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
+38 -4
View File
@@ -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()
+83
View File
@@ -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()
+4 -58
View File
@@ -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."""
+21 -2
View File
@@ -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()
@@ -79,6 +79,32 @@ 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.
+8 -1
View File
@@ -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")
+8 -2
View File
@@ -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:
+116
View File
@@ -0,0 +1,116 @@
"""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"
@@ -0,0 +1,157 @@
"""Đổ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,6 +31,7 @@ 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
@@ -0,0 +1,167 @@
"""Đổ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
@@ -0,0 +1,273 @@
"""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,6 +947,7 @@ 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:
@@ -1002,6 +1003,7 @@ 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: