Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddb31f9aff | ||
|
|
b500b3e57d | ||
|
|
76225aa118 | ||
|
|
35334274eb | ||
|
|
10b8379824 | ||
|
|
8c497cf50a | ||
|
|
b78d48320c | ||
|
|
b7a41b3658 | ||
|
|
bbdf146d2c | ||
|
|
35f24e0e28 | ||
|
|
2759ed94ba | ||
|
|
8548c1e923 | ||
|
|
caf3b74931 | ||
|
|
c00b83cd1a | ||
|
|
a04f8a928d | ||
|
|
cc8d5c8c0a | ||
|
|
2e3e719259 | ||
|
|
c699beb6fd | ||
|
|
7607f44030 |
+41
-2
@@ -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)"
|
||||
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Khoá phạm vi quét của màn GraphRAG vào một project.
|
||||
|
||||
Tách khỏi ``graph_renderer.py``: file đó đã ở 399/400 dòng — đúng một dòng
|
||||
trước trần của ``scripts/check_loc.py``, và cổng ấy nói rõ cách duy nhất đúng
|
||||
khi chạm trần là tách file, không phải nới con số. Khối này là chỗ tự nhiên
|
||||
để cắt: ba phương thức dưới đây chỉ nói về một việc — project nào đang khoá,
|
||||
và thư mục nào đi theo nó — còn phần còn lại của renderer lo việc vẽ.
|
||||
|
||||
Là mixin chứ không phải đối tượng rời, cùng lý do như ``NavRailMixin``: ba
|
||||
phương thức này đọc/ghi state của chính renderer (``project_combo``,
|
||||
``path_edit``, ``_needs_scan``…). Biến thành đối tượng cộng tác thì phải viết
|
||||
lại từng chỗ ``self.X`` thành ``self.renderer.X`` mà không đổi hành vi gì.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.i18n import tr
|
||||
|
||||
|
||||
class GraphProjectLockMixin:
|
||||
"""Ba phương thức khoá-theo-project. Trộn vào ``GraphRenderer``."""
|
||||
|
||||
def _refresh_project_combo(self) -> None:
|
||||
"""Nạp lại danh sách project vào bộ chọn, giữ nguyên project đang chọn."""
|
||||
from cowork_local.core.projects import list_projects
|
||||
|
||||
keep = self._active_project_id
|
||||
self.project_combo.blockSignals(True)
|
||||
self.project_combo.clear()
|
||||
self.project_combo.addItem(tr("structure.project_none"), "")
|
||||
row_to_select = 0
|
||||
for i, p in enumerate(list_projects(), start=1):
|
||||
self.project_combo.addItem(p.name, p.project_id)
|
||||
if p.project_id == keep:
|
||||
row_to_select = i
|
||||
self.project_combo.setCurrentIndex(row_to_select)
|
||||
self.project_combo.blockSignals(False)
|
||||
|
||||
def set_project(self, project_id: str) -> None:
|
||||
"""Khoá phạm vi quét vào một project (chuỗi rỗng là bỏ khoá)."""
|
||||
pid = project_id or ""
|
||||
self._refresh_project_combo()
|
||||
target = self.project_combo.findData(pid)
|
||||
if target < 0:
|
||||
target = 0
|
||||
if self.project_combo.currentIndex() == target:
|
||||
self._on_project_changed(target)
|
||||
else:
|
||||
self.project_combo.setCurrentIndex(target)
|
||||
|
||||
def _on_project_changed(self, _idx: int) -> None:
|
||||
"""Áp trạng thái khoá: đường dẫn chuyển sang chỉ đọc và trỏ vào thư mục"""
|
||||
from cowork_local.core.projects import load_project
|
||||
|
||||
pid = self.project_combo.currentData() or ""
|
||||
project_changed = pid != self._active_project_id
|
||||
self._active_project_id = pid
|
||||
locked = bool(pid)
|
||||
self.path_edit.setReadOnly(locked)
|
||||
self._pick_btn.setEnabled(not locked)
|
||||
if locked:
|
||||
project = load_project(pid)
|
||||
if project is not None:
|
||||
self.path_edit.setText(str(project.workspace_dir()))
|
||||
# Changing the FOLDER changes what we scan just as much as changing the
|
||||
# project does. Keying this off the id alone left the path in the bar
|
||||
# updated while the graph in the middle still showed the old folder's
|
||||
# nodes: "Đổi" in the Project screen moves the folder, never the id.
|
||||
duong_dan = self.path_edit.text().strip()
|
||||
doi_muc_tieu = project_changed or duong_dan != self._active_path
|
||||
self._active_path = duong_dan
|
||||
if doi_muc_tieu:
|
||||
self.project_changed.emit() # GraphQaWidget drops its temp extraction cache
|
||||
# Mark it and scan on the next visit rather than now — see
|
||||
# auto_scan_and_fit()'s docstring for why.
|
||||
self._needs_scan = True
|
||||
# ...except when this screen is the one on show. The picker lives HERE,
|
||||
# so a user changing project is already looking at the graph: there is
|
||||
# no "next visit" to defer to, and they had to press Scan by hand.
|
||||
# Deferring still applies when the change came from the Workspace
|
||||
# screen while this one is hidden, which is what it was for.
|
||||
if self.isVisible() and duong_dan:
|
||||
self._needs_scan = False
|
||||
self._scan()
|
||||
@@ -27,6 +27,7 @@ from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.graph import graph_export
|
||||
from cowork_local.presentation.graph.graph_messages_view import GraphMessagesView
|
||||
from cowork_local.presentation.graph.graph_project_lock import GraphProjectLockMixin
|
||||
from cowork_local.presentation.graph.graph_scene_builder import build_scene
|
||||
from cowork_local.presentation.graph.graph_scene_items import _Bridge, _Edge, _GraphView, _Node
|
||||
from cowork_local.presentation.shared import HAS_WEB_ENGINE
|
||||
@@ -35,7 +36,7 @@ from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
class GraphRenderer(QWidget):
|
||||
class GraphRenderer(GraphProjectLockMixin, QWidget):
|
||||
"""Nửa "đồ thị" của màn GraphRAG: thanh công cụ, khung xem và vòng đời quét."""
|
||||
status_message = Signal(str)
|
||||
node_selected = Signal(object) # a node's .data, whenever the scene selection changes
|
||||
@@ -60,6 +61,8 @@ class GraphRenderer(QWidget):
|
||||
self._needs_scan = False
|
||||
self._scan_seq = 0 # only the latest scan's result is rendered (no stale overwrite)
|
||||
self._active_project_id = "" # "" = free path; set = scan locked to that project's sandbox
|
||||
# The folder last scanned — see graph_project_lock.py.
|
||||
self._active_path = ""
|
||||
|
||||
self._rescan_timer = QTimer(self)
|
||||
self._rescan_timer.setSingleShot(True)
|
||||
@@ -154,63 +157,6 @@ class GraphRenderer(QWidget):
|
||||
"""
|
||||
return [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)]
|
||||
|
||||
# ---- project sandbox lock ------------------------------------------------- #
|
||||
def _refresh_project_combo(self) -> None:
|
||||
"""Nạp lại danh sách project vào bộ chọn, giữ nguyên project đang chọn."""
|
||||
from cowork_local.core.projects import list_projects
|
||||
|
||||
keep = self._active_project_id
|
||||
self.project_combo.blockSignals(True)
|
||||
self.project_combo.clear()
|
||||
self.project_combo.addItem(tr("structure.project_none"), "")
|
||||
row_to_select = 0
|
||||
for i, p in enumerate(list_projects(), start=1):
|
||||
self.project_combo.addItem(p.name, p.project_id)
|
||||
if p.project_id == keep:
|
||||
row_to_select = i
|
||||
self.project_combo.setCurrentIndex(row_to_select)
|
||||
self.project_combo.blockSignals(False)
|
||||
|
||||
def set_project(self, project_id: str) -> None:
|
||||
"""Khoá phạm vi quét vào một project (chuỗi rỗng là bỏ khoá)."""
|
||||
pid = project_id or ""
|
||||
self._refresh_project_combo()
|
||||
target = self.project_combo.findData(pid)
|
||||
if target < 0:
|
||||
target = 0
|
||||
if self.project_combo.currentIndex() == target:
|
||||
self._on_project_changed(target)
|
||||
else:
|
||||
self.project_combo.setCurrentIndex(target)
|
||||
|
||||
def _on_project_changed(self, _idx: int) -> None:
|
||||
"""Áp trạng thái khoá: đường dẫn chuyển sang chỉ đọc và trỏ vào thư mục"""
|
||||
from cowork_local.core.projects import load_project
|
||||
|
||||
pid = self.project_combo.currentData() or ""
|
||||
project_changed = pid != self._active_project_id
|
||||
self._active_project_id = pid
|
||||
locked = bool(pid)
|
||||
self.path_edit.setReadOnly(locked)
|
||||
self._pick_btn.setEnabled(not locked)
|
||||
if locked:
|
||||
project = load_project(pid)
|
||||
if project is not None:
|
||||
self.path_edit.setText(str(project.workspace_dir()))
|
||||
if project_changed:
|
||||
self.project_changed.emit() # GraphQaWidget drops its temp extraction cache
|
||||
# Mark it and scan on the next visit rather than now — see
|
||||
# auto_scan_and_fit()'s docstring for why.
|
||||
self._needs_scan = True
|
||||
# ...except when this screen is the one on show. The picker lives HERE,
|
||||
# so a user changing project is already looking at the graph: there is
|
||||
# no "next visit" to defer to, and they had to press Scan by hand.
|
||||
# Deferring still applies when the change came from the Workspace
|
||||
# screen while this one is hidden, which is what it was for.
|
||||
if self.isVisible() and self.path_edit.text().strip():
|
||||
self._needs_scan = False
|
||||
self._scan()
|
||||
|
||||
# ---- helpers ---------------------------------------------------------------- #
|
||||
def _pick(self) -> None:
|
||||
"""Mở hộp thoại chọn thư mục gốc để quét."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -278,10 +278,17 @@ class AnthropicProvider(Provider):
|
||||
raise ProviderError(f"Anthropic: {evt.get('error', {}).get('message', 'error')}")
|
||||
resp.close()
|
||||
break # stream finished normally (or cancelled)
|
||||
except requests.RequestException as exc:
|
||||
except Exception as exc: # noqa: BLE001 — lọc lại ngay bên dưới
|
||||
resp.close()
|
||||
# Huỷ giữa chừng đóng socket, và urllib3 ném AttributeError
|
||||
# ("'NoneType' object has no attribute 'read'") chứ KHÔNG phải
|
||||
# RequestException — bắt hẹp là lỗi đó lọt ra ngoài và người dùng
|
||||
# thấy một lỗi Python đỏ thay vì "đã dừng". Chỉ nuốt khi thật sự
|
||||
# đang huỷ; lỗi khác vẫn ném tiếp nguyên vẹn.
|
||||
if self._is_cancelled(cancel):
|
||||
break
|
||||
if not isinstance(exc, requests.RequestException):
|
||||
raise
|
||||
if text_parts or blocks:
|
||||
if on_text:
|
||||
on_text("\n⚠ Kết nối bị ngắt giữa chừng — hiển thị phần đã nhận được.\n")
|
||||
|
||||
@@ -254,13 +254,19 @@ class OpenAICompatProvider(Provider):
|
||||
slot["args"] += fn["arguments"]
|
||||
resp.close()
|
||||
break # stream finished normally (or cancelled)
|
||||
except requests.RequestException as exc:
|
||||
except Exception as exc: # noqa: BLE001 — lọc lại ngay bên dưới
|
||||
resp.close()
|
||||
# If cancel was requested, close cleanly without retry
|
||||
# Huỷ giữa chừng đóng socket, và urllib3 ném AttributeError
|
||||
# ("'NoneType' object has no attribute 'read'") chứ KHÔNG phải
|
||||
# RequestException — bắt hẹp là lỗi đó lọt ra ngoài và người dùng
|
||||
# thấy một lỗi Python đỏ thay vì "đã dừng". Chỉ nuốt khi thật sự
|
||||
# đang huỷ; lỗi khác vẫn ném tiếp nguyên vẹn.
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
break
|
||||
if self._is_cancelled(cancel):
|
||||
break
|
||||
if not isinstance(exc, requests.RequestException):
|
||||
raise
|
||||
if text_parts or tool_acc:
|
||||
# Partial answer already on screen — keep it, note the cut.
|
||||
if on_text:
|
||||
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user