Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6b68a0bf8 | ||
|
|
0b6b220bd9 | ||
|
|
fefc9f94db | ||
|
|
ddb31f9aff | ||
|
|
b500b3e57d | ||
|
|
76225aa118 | ||
|
|
35334274eb | ||
|
|
10b8379824 | ||
|
|
8c497cf50a | ||
|
|
b78d48320c | ||
|
|
b7a41b3658 | ||
|
|
bbdf146d2c | ||
|
|
35f24e0e28 | ||
|
|
2759ed94ba | ||
|
|
8548c1e923 | ||
|
|
caf3b74931 | ||
|
|
c00b83cd1a | ||
|
|
a04f8a928d | ||
|
|
cc8d5c8c0a | ||
|
|
2e3e719259 | ||
|
|
c699beb6fd | ||
|
|
7607f44030 |
@@ -72,6 +72,12 @@ def run(argv: List[str] | None = None) -> int:
|
|||||||
app = QApplication.instance() or QApplication(argv)
|
app = QApplication.instance() or QApplication(argv)
|
||||||
app.setApplicationName(APP_NAME)
|
app.setApplicationName(APP_NAME)
|
||||||
app.setWindowIcon(app_icon())
|
app.setWindowIcon(app_icon())
|
||||||
|
try:
|
||||||
|
from .config import CONFIG_DIR
|
||||||
|
from .presentation.shell import crash_guard
|
||||||
|
crash_guard.install(Path(CONFIG_DIR) / "logs")
|
||||||
|
except Exception: # noqa: BLE001 - crash logging must never block startup
|
||||||
|
crash_guard = None
|
||||||
# Composition Root: presentation/shell/bootstrap.py quyết định app chạy
|
# Composition Root: presentation/shell/bootstrap.py quyết định app chạy
|
||||||
# bằng mảnh nào. Từ R02, đó là JsonConfigRepository + kho bí mật của hệ
|
# bằng mảnh nào. Từ R02, đó là JsonConfigRepository + kho bí mật của hệ
|
||||||
# điều hành, không còn config.py::AppConfig.
|
# điều hành, không còn config.py::AppConfig.
|
||||||
@@ -132,4 +138,10 @@ def run(argv: List[str] | None = None) -> int:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
win.show()
|
win.show()
|
||||||
|
if crash_guard is not None:
|
||||||
|
try:
|
||||||
|
watchdog = crash_guard.HangWatchdog(DISPLAY_NAME, win)
|
||||||
|
app.aboutToQuit.connect(watchdog.stop)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
return app.exec()
|
return app.exec()
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
|
|||||||
# file (~/.cowork_local/assessments.json + assessments_history/), not here —
|
# file (~/.cowork_local/assessments.json + assessments_history/), not here —
|
||||||
# this section is only the behaviour config the user edits.
|
# this section is only the behaviour config the user edits.
|
||||||
"routing": {
|
"routing": {
|
||||||
"switch_mode": "off", # global default: "off" | "auto" | "manual"
|
"switch_mode": "auto", # global default: "auto" | "manual"
|
||||||
"policy": "balanced", # "quality" | "cost" | "latency" | "balanced"
|
"policy": "balanced", # "quality" | "cost" | "latency" | "balanced"
|
||||||
"min_score_gain": 0.05, # only switch if the new model beats current by ≥ this
|
"min_score_gain": 0.05, # only switch if the new model beats current by ≥ this
|
||||||
"confirm_timeout_sec": 60, # (manual) auto-keep current if the user doesn't confirm in time
|
"confirm_timeout_sec": 60, # (manual) auto-keep current if the user doesn't confirm in time
|
||||||
@@ -246,7 +246,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
|
|||||||
"judge_model": "", # fixed cheap judge model ("" → a per-provider default)
|
"judge_model": "", # fixed cheap judge model ("" → a per-provider default)
|
||||||
"candidates": [], # explicit [{provider, model_id, tier}]; empty → discover from providers
|
"candidates": [], # explicit [{provider, model_id, tier}]; empty → discover from providers
|
||||||
"auto_reassess_on_add": True, # reassess a newly-added model as soon as it's added
|
"auto_reassess_on_add": True, # reassess a newly-added model as soon as it's added
|
||||||
# Per-surface Off/Auto/Manual toggle state (the chat-screen toggle). An
|
# Per-surface Auto/Manual toggle state (the chat-screen toggle). An
|
||||||
# empty string means "follow the global switch_mode above".
|
# empty string means "follow the global switch_mode above".
|
||||||
"surface_modes": {
|
"surface_modes": {
|
||||||
"cowork": "",
|
"cowork": "",
|
||||||
|
|||||||
+41
-2
@@ -12,6 +12,7 @@ sort by recency. History can live locally or in a OneDrive folder (resolved by
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List
|
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]
|
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:
|
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.
|
"""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:
|
for m in messages:
|
||||||
if m.get("role") == "user" and m.get("content"):
|
if m.get("role") == "user" and m.get("content"):
|
||||||
text = " ".join(m["content"].split())
|
return shorten_title(" ".join(m["content"].split()))
|
||||||
return text[:60] + ("…" if len(text) > 60 else "")
|
|
||||||
return "(empty)"
|
return "(empty)"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -75,26 +75,16 @@ class RoutingScheduler(QObject):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _routing_enabled_anywhere(self) -> bool:
|
def _routing_enabled_anywhere(self) -> bool:
|
||||||
"""Is routing actually in use? True if the global mode is auto/manual OR
|
"""Is routing actually in use? Always, now: the Off mode was removed and
|
||||||
any chat surface overrides to auto/manual. When everything is Off, the
|
every stored value resolves to Auto or Manual (see
|
||||||
assessment scores would never be consulted — so we don't spend tokens
|
``config.user_routing_mode``). Paid probing is switched off through
|
||||||
probing for them (no surprise cost on a fresh install)."""
|
``reassess_interval_hours = 0`` instead."""
|
||||||
try:
|
return True
|
||||||
routing = self.ctx.config.routing
|
|
||||||
if (routing.get("switch_mode") or "off") in ("auto", "manual"):
|
|
||||||
return True
|
|
||||||
for m in (routing.get("surface_modes") or {}).values():
|
|
||||||
if m in ("auto", "manual"):
|
|
||||||
return True
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
pass
|
|
||||||
return False
|
|
||||||
|
|
||||||
def is_due(self) -> bool:
|
def is_due(self) -> bool:
|
||||||
"""Đã đến lúc chấm điểm lại chưa.
|
"""Đã đến lúc chấm điểm lại chưa.
|
||||||
|
|
||||||
Tắt định tuyến ở mọi bề mặt thì KHÔNG dò — dò model là lượt gọi có tính phí,
|
Dò model là lượt gọi có tính phí: đặt chu kỳ chấm lại = 0 thì không dò.
|
||||||
không được tiêu tiền cho một tính năng người dùng đã tắt.
|
|
||||||
"""
|
"""
|
||||||
if not self._routing_enabled_anywhere():
|
if not self._routing_enabled_anywhere():
|
||||||
return False # routing off everywhere → don't probe (would be wasted cost)
|
return False # routing off everywhere → don't probe (would be wasted cost)
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
|||||||
"chatpanel.working": {"en": "{name}: working…", "ja": "{name}: 処理中…", "vi": "{name}: đang xử lý…"},
|
"chatpanel.working": {"en": "{name}: working…", "ja": "{name}: 処理中…", "vi": "{name}: đang xử lý…"},
|
||||||
"chatpanel.done": {"en": "{name}: done.", "ja": "{name}: 完了。", "vi": "{name}: xong."},
|
"chatpanel.done": {"en": "{name}: done.", "ja": "{name}: 完了。", "vi": "{name}: xong."},
|
||||||
"chatpanel.failed": {"en": "{name}: error.", "ja": "{name}: エラー。", "vi": "{name}: lỗi."},
|
"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.stopping": {"en": "{name}: stopping…", "ja": "{name}: 停止中…", "vi": "{name}: đang dừng…"},
|
||||||
"chatpanel.attach_limit": {
|
"chatpanel.attach_limit": {
|
||||||
"en": "Max {n} attachments — extra files were skipped.",
|
"en": "Max {n} attachments — extra files were skipped.",
|
||||||
|
|||||||
@@ -155,6 +155,12 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
|||||||
"app.lang.switching": {
|
"app.lang.switching": {
|
||||||
"en": "Switching language…", "ja": "言語を切り替えています…",
|
"en": "Switching language…", "ja": "言語を切り替えています…",
|
||||||
"vi": "Đang đổi ngôn ngữ…"},
|
"vi": "Đang đổi ngôn ngữ…"},
|
||||||
|
# Popup do luồng canh treo mở khi giao diện đứng quá vài giây
|
||||||
|
# (presentation/shell/crash_guard.py).
|
||||||
|
"app.hang.message": {
|
||||||
|
"en": "Processing, please wait…\n\nThis window closes by itself once the app responds again.",
|
||||||
|
"ja": "処理中です。しばらくお待ちください…\n\nアプリが応答を再開すると、このウィンドウは自動的に閉じます。",
|
||||||
|
"vi": "Đang xử lý, vui lòng đợi…\n\nCửa sổ này tự đóng khi app phản hồi lại."},
|
||||||
"app.tab.dashboard": {"en": "Dashboard", "ja": "ダッシュボード", "vi": "Dashboard"},
|
"app.tab.dashboard": {"en": "Dashboard", "ja": "ダッシュボード", "vi": "Dashboard"},
|
||||||
"app.tab.schedule": {"en": "Schedule Task", "ja": "タスクスケジュール", "vi": "Schedule Task"},
|
"app.tab.schedule": {"en": "Schedule Task", "ja": "タスクスケジュール", "vi": "Schedule Task"},
|
||||||
"app.tab.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
|
"app.tab.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
|
||||||
|
|||||||
@@ -229,6 +229,11 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
|||||||
"chat.open_folder": {"en": "Open folder", "ja": "フォルダを開く", "vi": "Mở thư mục"},
|
"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.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.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": {
|
"chat.session_folder_marker": {
|
||||||
"en": "This conversation's output folder", "ja": "この会話の出力フォルダ",
|
"en": "This conversation's output folder", "ja": "この会話の出力フォルダ",
|
||||||
"vi": "Thư mục output của hội thoại này"},
|
"vi": "Thư mục output của hội thoại này"},
|
||||||
|
|||||||
@@ -258,6 +258,14 @@ class JsonConfigRepository(ConfigSectionsMixin):
|
|||||||
#: là bản chính; ``tests/test_config_repository.py`` có bài đối chiếu với
|
#: là bản chính; ``tests/test_config_repository.py`` có bài đối chiếu với
|
||||||
#: ``config.py`` để hai bên lệch nhau là đỏ ngay.
|
#: ``config.py`` để hai bên lệch nhau là đỏ ngay.
|
||||||
ROUTING_MODES = ("off", "auto", "manual", "fallback")
|
ROUTING_MODES = ("off", "auto", "manual", "fallback")
|
||||||
|
#: Chế độ người dùng còn chọn được. "off"/"fallback" đã bỏ khỏi giao diện;
|
||||||
|
#: giá trị cũ còn lưu trong config/project (hoặc giá trị lạ) đều hiểu là "auto".
|
||||||
|
USER_ROUTING_MODES = ("auto", "manual")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def user_routing_mode(cls, mode: str) -> str:
|
||||||
|
"""Quy một giá trị đã lưu về chế độ người dùng chọn được (mặc định "auto")."""
|
||||||
|
return mode if mode in cls.USER_ROUTING_MODES else "auto"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls, path: Path | None = None, *, secrets: SecretStore | None = None):
|
def load(cls, path: Path | None = None, *, secrets: SecretStore | None = None):
|
||||||
@@ -313,16 +321,14 @@ class JsonConfigRepository(ConfigSectionsMixin):
|
|||||||
"""Chế độ có hiệu lực cho một bề mặt chat.
|
"""Chế độ có hiệu lực cho một bề mặt chat.
|
||||||
|
|
||||||
Đặt riêng cho bề mặt thì thắng; để trống thì lấy ``switch_mode`` chung.
|
Đặt riêng cho bề mặt thì thắng; để trống thì lấy ``switch_mode`` chung.
|
||||||
Giá trị lạ rơi về "off" — định tuyến luôn là thứ phải bật, kể cả khi
|
Chỉ còn Auto/Manual: "off", "fallback" cũ hay giá trị lạ đều hiểu là "auto"."""
|
||||||
có người sửa tay file cấu hình."""
|
|
||||||
routing = self.routing
|
routing = self.routing
|
||||||
override = (routing.get("surface_modes", {}) or {}).get(surface, "")
|
override = (routing.get("surface_modes", {}) or {}).get(surface, "")
|
||||||
mode = override or routing.get("switch_mode", "off")
|
return self.user_routing_mode(override or routing.get("switch_mode", ""))
|
||||||
return mode if mode in self.ROUTING_MODES else "off"
|
|
||||||
|
|
||||||
def set_routing_mode_for(self, surface: str, mode: str) -> None:
|
def set_routing_mode_for(self, surface: str, mode: str) -> None:
|
||||||
"""Đặt chế độ định tuyến riêng cho một bề mặt chat, ghi đĩa ngay."""
|
"""Đặt chế độ định tuyến riêng cho một bề mặt chat, ghi đĩa ngay."""
|
||||||
mode = mode if mode in self.ROUTING_MODES else "off"
|
mode = self.user_routing_mode(mode)
|
||||||
self.routing.setdefault("surface_modes", {})[surface] = mode
|
self.routing.setdefault("surface_modes", {})[surface] = mode
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ from typing import Any, Dict, List, Optional
|
|||||||
|
|
||||||
_PLAN_ICONS = {"pending": "○", "running": "▶", "done": "✓", "error": "✗"}
|
_PLAN_ICONS = {"pending": "○", "running": "▶", "done": "✓", "error": "✗"}
|
||||||
|
|
||||||
|
HEADER_TITLE_MAX_CHARS = 10 # thanh tiêu đề khung chat chỉ hiện tối đa ngần này ký tự
|
||||||
|
|
||||||
|
|
||||||
|
def clip_chars(text: str, limit: int = HEADER_TITLE_MAX_CHARS) -> str:
|
||||||
|
"""Giữ tối đa ``limit`` ký tự, dư thì cắt và thêm "…"."""
|
||||||
|
return text if len(text) <= limit else text[:limit].rstrip() + "…"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _format_plan_steps(steps) -> str:
|
def _format_plan_steps(steps) -> str:
|
||||||
|
|||||||
@@ -99,6 +99,9 @@ class ChatPanel(ChatLiveTurnsMixin, ChatPanelLayoutMixin, ChatEventStreamMixin,
|
|||||||
# can run concurrently. Each value is a turn-context dict — see _start_turn.
|
# can run concurrently. Each value is a turn-context dict — see _start_turn.
|
||||||
self.worker: AgentWorker | None = None
|
self.worker: AgentWorker | None = None
|
||||||
self._active: Dict[AgentWorker, Dict[str, Any]] = {}
|
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
|
self._turn_seq: int = 0
|
||||||
# session_id -> its live messages list, for every conversation that still has
|
# 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
|
# 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
|
Switching chats, or hitting History → Refresh, shows whether THIS chat is
|
||||||
still processing (a background turn) or idle."""
|
still processing (a background turn) or idle."""
|
||||||
if self._view_busy():
|
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:
|
else:
|
||||||
self.thinking.stop()
|
self.thinking.stop()
|
||||||
self.composer.set_running(bool(self._active)) # Stop shows while anything runs
|
self.composer.set_running(bool(self._active)) # Stop shows while anything runs
|
||||||
|
|||||||
@@ -224,6 +224,23 @@ class ChatSessionMixin:
|
|||||||
if getattr(self, "_usage_total_lbl", None) is not None:
|
if getattr(self, "_usage_total_lbl", None) is not None:
|
||||||
self.refresh_usage()
|
self.refresh_usage()
|
||||||
|
|
||||||
|
def set_title_label(self, lbl, fallback: str) -> None:
|
||||||
|
"""Đặt tiêu đề hội thoại lên nhãn: tối đa 10 ký tự, bản đầy đủ ở tooltip."""
|
||||||
|
from .chat_helpers import clip_chars
|
||||||
|
|
||||||
|
full = self.title or fallback
|
||||||
|
lbl.setText(clip_chars(full))
|
||||||
|
lbl.setToolTip(full)
|
||||||
|
|
||||||
|
def apply_renamed_title(self, session_id: str, title: str) -> None:
|
||||||
|
"""Hội thoại đang mở vừa được đổi tên ở cột lịch sử: đổi luôn ``self.title``.
|
||||||
|
|
||||||
|
Không chỉ để thanh tiêu đề cập nhật ngay — lượt chat kế tiếp lưu bằng
|
||||||
|
``self.title``, giữ tên cũ sẽ ghi đè mất tên người dùng vừa đặt."""
|
||||||
|
if session_id and session_id == self.session_id:
|
||||||
|
self.title = title
|
||||||
|
self._notify_title()
|
||||||
|
|
||||||
def load_conversation(self, conv: Dict[str, Any]) -> None:
|
def load_conversation(self, conv: Dict[str, Any]) -> None:
|
||||||
"""Switch the view to a stored conversation. Allowed while work is running —
|
"""Switch the view to a stored conversation. Allowed while work is running —
|
||||||
the current turns keep going in the background."""
|
the current turns keep going in the background."""
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
from PySide6.QtCore import Qt, Signal
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
from ...core.history import shorten_title
|
||||||
from ...core.worker import AgentWorker
|
from ...core.worker import AgentWorker
|
||||||
from ...i18n import tr
|
from ...i18n import tr
|
||||||
from ...state import AppContext
|
from ...state import AppContext
|
||||||
@@ -88,7 +89,11 @@ class ChatTurnRunnerMixin:
|
|||||||
prefix = f"{prefix}\n\n{agent_prefix}" if prefix else agent_prefix
|
prefix = f"{prefix}\n\n{agent_prefix}" if prefix else agent_prefix
|
||||||
if not self.title:
|
if not self.title:
|
||||||
base = text or (Path(attachments[0]).name if attachments else "(attachment)")
|
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()
|
self._notify_title()
|
||||||
|
|
||||||
# Reset the Plan panel so each message starts from a clean checklist (the
|
# 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():
|
if self._view_busy() or len(self._active) >= self._max_parallel():
|
||||||
self.composer.set_busy(True)
|
self.composer.set_busy(True)
|
||||||
self.status_message.emit(tr("chatpanel.working", name=tr(f"app.tab.{self.kind}")))
|
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")
|
self.thinking.start("chat.running")
|
||||||
worker.start()
|
worker.start()
|
||||||
|
|
||||||
@@ -254,7 +260,7 @@ class ChatTurnRunnerMixin:
|
|||||||
self._show_usage(ctx) # per-turn + conversation token/cost
|
self._show_usage(ctx) # per-turn + conversation token/cost
|
||||||
except Exception: # noqa: BLE001 — usage display must never break a turn
|
except Exception: # noqa: BLE001 — usage display must never break a turn
|
||||||
pass
|
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()
|
folder = self.workspace_dir()
|
||||||
if folder:
|
if folder:
|
||||||
done.add_folder_link(str(folder), tr("chat.open_output_folder"))
|
done.add_folder_link(str(folder), tr("chat.open_output_folder"))
|
||||||
@@ -268,12 +274,32 @@ class ChatTurnRunnerMixin:
|
|||||||
self._maybe_notify_teams(result)
|
self._maybe_notify_teams(result)
|
||||||
self._drain_queue()
|
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:
|
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ở)."""
|
"""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)
|
live = self._turn_is_live(ctx)
|
||||||
self._end_turn(ctx)
|
self._end_turn(ctx)
|
||||||
self._cleanup_turn(ctx, False) # discard this turn's output sandbox
|
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.chat_view.add_error(err)
|
||||||
self.graph_event.emit(self.session_name, {"type": "error", "content": err})
|
self.graph_event.emit(self.session_name, {"type": "error", "content": err})
|
||||||
from ...providers.base import is_model_not_found_error
|
from ...providers.base import is_model_not_found_error
|
||||||
@@ -286,7 +312,10 @@ class ChatTurnRunnerMixin:
|
|||||||
self.composer.set_text(ctx["display_text"])
|
self.composer.set_text(ctx["display_text"])
|
||||||
else:
|
else:
|
||||||
self._persist_session(ctx)
|
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.turn_finished.emit({"error": err})
|
||||||
self._drain_queue()
|
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."""
|
"""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:
|
if not self._active:
|
||||||
return
|
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):
|
for w in list(self._active):
|
||||||
if w.isRunning():
|
if w.isRunning():
|
||||||
w.request_stop()
|
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.i18n import on_language_changed, tr
|
||||||
from cowork_local.presentation.graph import graph_export
|
from cowork_local.presentation.graph import graph_export
|
||||||
from cowork_local.presentation.graph.graph_messages_view import GraphMessagesView
|
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_builder import build_scene
|
||||||
from cowork_local.presentation.graph.graph_scene_items import _Bridge, _Edge, _GraphView, _Node
|
from cowork_local.presentation.graph.graph_scene_items import _Bridge, _Edge, _GraphView, _Node
|
||||||
from cowork_local.presentation.shared import HAS_WEB_ENGINE
|
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
|
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."""
|
"""Nửa "đồ thị" của màn GraphRAG: thanh công cụ, khung xem và vòng đời quét."""
|
||||||
status_message = Signal(str)
|
status_message = Signal(str)
|
||||||
node_selected = Signal(object) # a node's .data, whenever the scene selection changes
|
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._needs_scan = False
|
||||||
self._scan_seq = 0 # only the latest scan's result is rendered (no stale overwrite)
|
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
|
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 = QTimer(self)
|
||||||
self._rescan_timer.setSingleShot(True)
|
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)]
|
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 ---------------------------------------------------------------- #
|
# ---- helpers ---------------------------------------------------------------- #
|
||||||
def _pick(self) -> None:
|
def _pick(self) -> None:
|
||||||
"""Mở hộp thoại chọn thư mục gốc để quét."""
|
"""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
|
# 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.
|
# nao, de lan goi dau tien khong bi bo qua ke ca khi pid la chuoi rong.
|
||||||
self._workspace_project = None
|
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)
|
root = QVBoxLayout(self)
|
||||||
self.renderer = GraphRenderer(ctx)
|
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ư
|
Đổ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
|
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
|
return
|
||||||
self._workspace_project = project_id
|
self._workspace_project = project_id
|
||||||
|
self._workspace_dir = thu_muc
|
||||||
self.set_project(project_id)
|
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:
|
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."""
|
"""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()
|
self.renderer.prewarm()
|
||||||
|
|||||||
@@ -12,11 +12,9 @@ from PySide6.QtWidgets import (
|
|||||||
|
|
||||||
from ...i18n import tr
|
from ...i18n import tr
|
||||||
|
|
||||||
#: Các chế độ định tuyến. Danh sách này phải khớp ``config.py::AppConfig
|
#: Các chế độ người dùng chọn được — khớp ``AppConfig.USER_ROUTING_MODES``.
|
||||||
#: .ROUTING_MODES`` — Delta thêm "fallback" ở R03-T03 và nếu quên đồng bộ
|
#: Off/Fallback đã bỏ; giá trị cũ còn lưu được hiểu là Auto.
|
||||||
#: chỗ này thì người dùng không chọn được chế độ đó, mà không có lỗi nào báo.
|
MODE_KEYS = (("auto", "routing.mode_auto"), ("manual", "routing.mode_manual"))
|
||||||
MODE_KEYS = (("off", "routing.mode_off"), ("auto", "routing.mode_auto"),
|
|
||||||
("manual", "routing.mode_manual"))
|
|
||||||
|
|
||||||
POLICY_KEYS = (("quality", "routing.policy_quality"), ("cost", "routing.policy_cost"),
|
POLICY_KEYS = (("quality", "routing.policy_quality"), ("cost", "routing.policy_cost"),
|
||||||
("latency", "routing.policy_latency"),
|
("latency", "routing.policy_latency"),
|
||||||
@@ -37,7 +35,7 @@ class RoutingSettingsWidget(QGroupBox):
|
|||||||
self.mode = QComboBox()
|
self.mode = QComboBox()
|
||||||
for value, key in MODE_KEYS:
|
for value, key in MODE_KEYS:
|
||||||
self.mode.addItem(tr(key), value)
|
self.mode.addItem(tr(key), value)
|
||||||
_select(self.mode, routing.get("switch_mode", "off"))
|
_select(self.mode, routing.get("switch_mode", "auto")) # off/fallback cũ → mục đầu (Auto)
|
||||||
form.addRow(tr("routing.settings_mode"), self.mode)
|
form.addRow(tr("routing.settings_mode"), self.mode)
|
||||||
|
|
||||||
self.policy = QComboBox()
|
self.policy = QComboBox()
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Ghi lại mọi lần app chết hoặc treo, và báo "Đang xử lý…" khi giao diện đứng.
|
||||||
|
|
||||||
|
Trước đây app văng ra (mã 0xC0000409) mà không để lại dấu vết gì: lỗi ở tầng
|
||||||
|
native không in traceback Python, còn cửa sổ console thì đóng ngay theo app. Mô-đun
|
||||||
|
này ghi mọi thứ vào ``~/.cowork_local/logs/``:
|
||||||
|
|
||||||
|
* ``crash.log``: stack của mọi luồng lúc tiến trình chết (``faulthandler``), kèm
|
||||||
|
exception Python không ai bắt, ở luồng chính lẫn luồng nền. Exception Python
|
||||||
|
chỉ được ghi lại, app vẫn chạy tiếp.
|
||||||
|
* ``qt.log``: cảnh báo, lỗi và lỗi nghiêm trọng của Qt (cũng vẫn in ra console).
|
||||||
|
* ``hang.log``: stack của mọi luồng mỗi khi luồng giao diện đứng quá
|
||||||
|
``HANG_SECONDS`` giây, để biết app đang kẹt ở dòng nào.
|
||||||
|
|
||||||
|
Luồng giao diện đang đứng thì không thể tự vẽ popup, nên popup "Đang xử lý…" do một
|
||||||
|
luồng canh riêng mở bằng hộp thoại gốc của Windows. Giao diện chạy lại thì hộp
|
||||||
|
thoại tự đóng.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import faulthandler
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from PySide6.QtCore import QTimer, QtMsgType, qInstallMessageHandler
|
||||||
|
|
||||||
|
HANG_SECONDS = 5.0 # luồng giao diện đứng quá ngần này giây thì coi là treo
|
||||||
|
_BEAT_MS = 500 # nhịp luồng giao diện báo "vẫn sống"
|
||||||
|
|
||||||
|
_crash_file = None # giữ file mở suốt đời app: faulthandler ghi vào lúc chết
|
||||||
|
_log_dir: Optional[Path] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _stamp() -> str:
|
||||||
|
"""Mốc giờ hiện tại, dùng làm tiêu đề mỗi mục trong log."""
|
||||||
|
return datetime.datetime.now().isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def _append(name: str, text: str) -> None:
|
||||||
|
"""Ghi thêm vào một file log. Ghi log hỏng thì bỏ qua, không kéo app theo."""
|
||||||
|
if _log_dir is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(_log_dir / name, "a", encoding="utf-8") as f:
|
||||||
|
f.write(text)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _log_exception(where: str, exc_type, exc, tb) -> None:
|
||||||
|
"""Ghi một exception không ai bắt vào crash.log và in ra console."""
|
||||||
|
body = "".join(traceback.format_exception(exc_type, exc, tb))
|
||||||
|
_append("crash.log", f"\n=== {_stamp()} exception chưa bắt ({where}) ===\n{body}")
|
||||||
|
if sys.__stderr__:
|
||||||
|
sys.__stderr__.write(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _qt_message(mode, context, message) -> None:
|
||||||
|
"""Chuyển thông báo của Qt vào qt.log, vẫn in ra console như trước."""
|
||||||
|
level = {QtMsgType.QtWarningMsg: "WARNING", QtMsgType.QtCriticalMsg: "CRITICAL",
|
||||||
|
QtMsgType.QtFatalMsg: "FATAL"}.get(mode)
|
||||||
|
if sys.__stderr__:
|
||||||
|
sys.__stderr__.write(message + "\n")
|
||||||
|
if level is None:
|
||||||
|
return # debug/info: chỉ in, không ghi file
|
||||||
|
_append("qt.log", f"{_stamp()} {level}: {message}\n")
|
||||||
|
if mode == QtMsgType.QtFatalMsg and _crash_file is not None:
|
||||||
|
faulthandler.dump_traceback(_crash_file, all_threads=True)
|
||||||
|
|
||||||
|
|
||||||
|
def install(log_dir: Path) -> None:
|
||||||
|
"""Bật ghi log crash. Gọi một lần, sớm nhất có thể sau khi có QApplication."""
|
||||||
|
global _crash_file, _log_dir
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
_log_dir = log_dir
|
||||||
|
_crash_file = open(log_dir / "crash.log", "a", encoding="utf-8") # noqa: SIM115
|
||||||
|
_crash_file.write(f"\n=== {_stamp()} app khởi động ===\n")
|
||||||
|
_crash_file.flush()
|
||||||
|
faulthandler.enable(file=_crash_file, all_threads=True)
|
||||||
|
sys.excepthook = lambda t, e, tb: _log_exception("luồng chính", t, e, tb)
|
||||||
|
threading.excepthook = lambda a: _log_exception(
|
||||||
|
f"luồng {a.thread.name if a.thread else '?'}", a.exc_type, a.exc_value, a.exc_traceback)
|
||||||
|
qInstallMessageHandler(_qt_message)
|
||||||
|
|
||||||
|
|
||||||
|
class HangWatchdog:
|
||||||
|
"""Canh luồng giao diện: đứng quá ``HANG_SECONDS`` giây thì ghi stack vào
|
||||||
|
hang.log và mở popup "Đang xử lý…", chạy lại thì đóng popup.
|
||||||
|
|
||||||
|
Luồng giao diện đều đặn cập nhật nhịp qua một ``QTimer``; một luồng nền kiểm tra
|
||||||
|
nhịp đó. Luồng nền không đụng vào widget Qt nào.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, title: str, parent=None) -> None:
|
||||||
|
self._title = title
|
||||||
|
self._last_beat = time.monotonic()
|
||||||
|
self._timer = QTimer(parent)
|
||||||
|
self._timer.timeout.connect(self._beat)
|
||||||
|
self._timer.start(_BEAT_MS)
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._popup: Optional[threading.Thread] = None
|
||||||
|
self._popup_title = title
|
||||||
|
threading.Thread(target=self._watch, name="hang-watchdog", daemon=True).start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Dừng canh (khi app thoát)."""
|
||||||
|
self._stop.set()
|
||||||
|
self._timer.stop()
|
||||||
|
|
||||||
|
def _beat(self) -> None:
|
||||||
|
self._last_beat = time.monotonic()
|
||||||
|
|
||||||
|
def _watch(self) -> None:
|
||||||
|
stalled = False
|
||||||
|
while not self._stop.wait(0.5):
|
||||||
|
lag = time.monotonic() - self._last_beat
|
||||||
|
if lag >= HANG_SECONDS and not stalled:
|
||||||
|
stalled = True
|
||||||
|
self._dump_stacks(lag)
|
||||||
|
self._show_popup()
|
||||||
|
elif lag < HANG_SECONDS and stalled:
|
||||||
|
stalled = False
|
||||||
|
self._close_popup()
|
||||||
|
|
||||||
|
def _dump_stacks(self, lag: float) -> None:
|
||||||
|
if _log_dir is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(_log_dir / "hang.log", "a", encoding="utf-8") as f:
|
||||||
|
f.write(f"\n=== {_stamp()} giao diện đứng {lag:.1f}s ===\n")
|
||||||
|
f.flush()
|
||||||
|
faulthandler.dump_traceback(f, all_threads=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _show_popup(self) -> None:
|
||||||
|
if sys.platform != "win32":
|
||||||
|
return
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
from ...i18n import tr
|
||||||
|
|
||||||
|
# Đọc chữ NGAY LÚC hiện popup: luôn khớp ngôn ngữ đang chọn trong app,
|
||||||
|
# kể cả khi người dùng vừa đổi ngôn ngữ.
|
||||||
|
title, message = self._title, tr("app.hang.message")
|
||||||
|
# MB_ICONINFORMATION | MB_TOPMOST | MB_SETFOREGROUND. MessageBoxW có vòng
|
||||||
|
# thông điệp riêng trên luồng này, nên hiện được dù luồng giao diện đang đứng.
|
||||||
|
flags = 0x40 | 0x40000 | 0x10000
|
||||||
|
self._popup_title = title
|
||||||
|
self._popup = threading.Thread(
|
||||||
|
target=lambda: ctypes.windll.user32.MessageBoxW(None, message, title, flags),
|
||||||
|
name="hang-popup", daemon=True)
|
||||||
|
self._popup.start()
|
||||||
|
|
||||||
|
def _close_popup(self) -> None:
|
||||||
|
if self._popup is None or sys.platform != "win32":
|
||||||
|
return
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
for _ in range(10): # hộp thoại có thể chưa kịp hiện ra
|
||||||
|
hwnd = user32.FindWindowW(None, self._popup_title)
|
||||||
|
if hwnd:
|
||||||
|
user32.PostMessageW(hwnd, 0x0010, 0, 0) # WM_CLOSE
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
self._popup = None
|
||||||
@@ -79,6 +79,32 @@ class ProjectFolderRuleMixin:
|
|||||||
self._gan_nhan_canh_bao_thu_muc()
|
self._gan_nhan_canh_bao_thu_muc()
|
||||||
self.project_selected.connect(self._sync_folder_warning)
|
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:
|
def _gan_nhan_canh_bao_thu_muc(self) -> None:
|
||||||
"""Chèn nhãn cảnh báo ngay DƯỚI hàng chứa ô Thư mục làm việc.
|
"""Chèn 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')}")
|
raise ProviderError(f"Anthropic: {evt.get('error', {}).get('message', 'error')}")
|
||||||
resp.close()
|
resp.close()
|
||||||
break # stream finished normally (or cancelled)
|
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()
|
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):
|
if self._is_cancelled(cancel):
|
||||||
break
|
break
|
||||||
|
if not isinstance(exc, requests.RequestException):
|
||||||
|
raise
|
||||||
if text_parts or blocks:
|
if text_parts or blocks:
|
||||||
if on_text:
|
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")
|
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"]
|
slot["args"] += fn["arguments"]
|
||||||
resp.close()
|
resp.close()
|
||||||
break # stream finished normally (or cancelled)
|
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()
|
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():
|
if cancel_event is not None and cancel_event.is_set():
|
||||||
break
|
break
|
||||||
if self._is_cancelled(cancel):
|
if self._is_cancelled(cancel):
|
||||||
break
|
break
|
||||||
|
if not isinstance(exc, requests.RequestException):
|
||||||
|
raise
|
||||||
if text_parts or tool_acc:
|
if text_parts or tool_acc:
|
||||||
# Partial answer already on screen — keep it, note the cut.
|
# Partial answer already on screen — keep it, note the cut.
|
||||||
if on_text:
|
if on_text:
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class AppContext:
|
|||||||
return load_project(pid)
|
return load_project(pid)
|
||||||
|
|
||||||
def project_routing_mode(self, surface: str) -> str:
|
def project_routing_mode(self, surface: str) -> str:
|
||||||
"""Effective Off/Auto/Manual/Fallback routing mode for a chat ``surface``
|
"""Effective Auto/Manual routing mode for a chat ``surface``
|
||||||
in the ACTIVE workspace: the workspace's own override wins; otherwise the
|
in the ACTIVE workspace: the workspace's own override wins; otherwise the
|
||||||
global default (``config.routing_mode_for``). This is what makes each
|
global default (``config.routing_mode_for``). This is what makes each
|
||||||
workspace keep its own routing mode.
|
workspace keep its own routing mode.
|
||||||
@@ -101,15 +101,15 @@ class AppContext:
|
|||||||
project = self._current_project()
|
project = self._current_project()
|
||||||
if project is not None:
|
if project is not None:
|
||||||
mode = (project.routing_modes or {}).get(surface, "")
|
mode = (project.routing_modes or {}).get(surface, "")
|
||||||
if mode in self.config.ROUTING_MODES:
|
if mode in self.config.ROUTING_MODES: # old off/fallback → auto
|
||||||
return mode
|
return self.config.user_routing_mode(mode)
|
||||||
return self.config.routing_mode_for(surface)
|
return self.config.routing_mode_for(surface)
|
||||||
|
|
||||||
def set_project_routing_mode(self, surface: str, mode: str) -> None:
|
def set_project_routing_mode(self, surface: str, mode: str) -> None:
|
||||||
"""Persist a surface's routing mode for the ACTIVE workspace. With no
|
"""Persist a surface's routing mode for the ACTIVE workspace. With no
|
||||||
workspace selected, falls back to the global setting so behaviour
|
workspace selected, falls back to the global setting so behaviour
|
||||||
outside a project stays global."""
|
outside a project stays global."""
|
||||||
mode = mode if mode in self.config.ROUTING_MODES else "off"
|
mode = self.config.user_routing_mode(mode)
|
||||||
project = self._current_project()
|
project = self._current_project()
|
||||||
if project is None:
|
if project is None:
|
||||||
self.config.set_routing_mode_for(surface, mode)
|
self.config.set_routing_mode_for(surface, mode)
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Đổi tên hội thoại ở cột lịch sử thì thanh tiêu đề Cowork đổi theo ngay.
|
||||||
|
|
||||||
|
Trước đây nhãn tiêu đề giữ tên cũ cho tới khi người dùng mở lại hội thoại, và
|
||||||
|
``self.title`` cũ còn ghi đè tên mới ở lượt chat kế tiếp.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
from cowork_local.config import AppConfig # noqa: E402
|
||||||
|
from cowork_local.presentation.chat.chat_helpers import clip_chars # noqa: E402
|
||||||
|
from cowork_local.state import AppContext # noqa: E402
|
||||||
|
|
||||||
|
pytest.importorskip("PySide6", reason="Qt is required for the integration suite")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def qt_app():
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cowork(qt_app, tmp_path: Path):
|
||||||
|
from cowork_local.ui.cowork_tab import CoworkTab
|
||||||
|
|
||||||
|
tab = CoworkTab(AppContext(AppConfig.load(tmp_path / "config.json")))
|
||||||
|
yield tab
|
||||||
|
tab.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def test_clip_chars_keeps_ten_characters():
|
||||||
|
assert clip_chars("Tên ngắn") == "Tên ngắn"
|
||||||
|
assert clip_chars("0123456789") == "0123456789"
|
||||||
|
assert clip_chars("0123456789AB") == "0123456789…"
|
||||||
|
assert clip_chars("Dự án mới toanh") == "Dự án mới…" # không để khoảng trắng trước "…"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_of_open_conversation_updates_header(cowork):
|
||||||
|
cowork.load_conversation({"session_id": "s1", "title": "Tên cũ", "messages": []})
|
||||||
|
assert cowork._title_lbl.text() == "Tên cũ"
|
||||||
|
|
||||||
|
cowork.apply_renamed_title("s1", "Tên mới")
|
||||||
|
|
||||||
|
assert cowork.title == "Tên mới" # lượt chat sau lưu bằng tên mới
|
||||||
|
assert cowork._title_lbl.text() == "Tên mới"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_of_other_conversation_is_ignored(cowork):
|
||||||
|
cowork.load_conversation({"session_id": "s1", "title": "Đang mở", "messages": []})
|
||||||
|
cowork.apply_renamed_title("s2", "Khác")
|
||||||
|
assert cowork._title_lbl.text() == "Đang mở"
|
||||||
|
|
||||||
|
|
||||||
|
def test_long_title_is_clipped_with_full_tooltip(cowork):
|
||||||
|
long_title = "Báo cáo doanh thu quý 3"
|
||||||
|
cowork.load_conversation({"session_id": "s1", "title": "x", "messages": []})
|
||||||
|
cowork.apply_renamed_title("s1", long_title)
|
||||||
|
assert cowork._title_lbl.text() == "Báo cáo do…"
|
||||||
|
assert cowork._title_lbl.toolTip() == long_title
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_list_clips_title_to_ten_chars(qt_app, tmp_path):
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
|
from cowork_local.core.history import save_conversation
|
||||||
|
from cowork_local.ui.sidebar import HistorySidebar
|
||||||
|
|
||||||
|
ctx = AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||||
|
ctx.config.data.setdefault("history", {})["custom_dir"] = str(tmp_path / "history")
|
||||||
|
assert ctx.config.history_dir() == tmp_path / "history" # không đọc lịch sử thật
|
||||||
|
title = "Báo cáo doanh thu quý 3"
|
||||||
|
save_conversation(tmp_path / "history", "cowork", "s1",
|
||||||
|
[{"role": "user", "content": "hi"}], title, project_id="p-test")
|
||||||
|
sb = HistorySidebar(ctx)
|
||||||
|
sb._project_filter = "p-test" # chỉ đọc history_dir() tạm, không gộp các project thật
|
||||||
|
sb.refresh()
|
||||||
|
items = []
|
||||||
|
stack = [sb.tree.topLevelItem(i) for i in range(sb.tree.topLevelItemCount())]
|
||||||
|
while stack:
|
||||||
|
it = stack.pop()
|
||||||
|
if it.data(0, Qt.UserRole):
|
||||||
|
items.append(it)
|
||||||
|
stack.extend(it.child(i) for i in range(it.childCount()))
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0].text(0).splitlines()[0] == "Báo cáo do…"
|
||||||
|
assert items[0].toolTip(0) == title
|
||||||
|
assert items[0].data(0, Qt.UserRole + 3) == title # đổi tên vẫn điền tên đầy đủ
|
||||||
|
sb.deleteLater()
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""crash_guard ghi lại exception chưa bắt và phát hiện giao diện bị treo."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
pytest.importorskip("PySide6")
|
||||||
|
|
||||||
|
from cowork_local.presentation.shell import crash_guard # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def qt_app():
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def guard(tmp_path, monkeypatch):
|
||||||
|
import faulthandler
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PySide6.QtCore import qInstallMessageHandler
|
||||||
|
|
||||||
|
monkeypatch.setattr(sys, "excepthook", sys.excepthook)
|
||||||
|
monkeypatch.setattr(threading, "excepthook", threading.excepthook)
|
||||||
|
crash_guard.install(tmp_path)
|
||||||
|
yield tmp_path
|
||||||
|
qInstallMessageHandler(None)
|
||||||
|
faulthandler.disable()
|
||||||
|
crash_guard._crash_file.close()
|
||||||
|
crash_guard._crash_file = None
|
||||||
|
crash_guard._log_dir = None
|
||||||
|
|
||||||
|
|
||||||
|
def test_uncaught_thread_exception_is_logged_not_fatal(guard):
|
||||||
|
def boom():
|
||||||
|
raise ValueError("lỗi luồng nền")
|
||||||
|
|
||||||
|
t = threading.Thread(target=boom, name="worker-x")
|
||||||
|
t.start()
|
||||||
|
t.join()
|
||||||
|
log = (guard / "crash.log").read_text(encoding="utf-8")
|
||||||
|
assert "ValueError: lỗi luồng nền" in log
|
||||||
|
assert "worker-x" in log
|
||||||
|
|
||||||
|
|
||||||
|
def test_watchdog_dumps_stacks_and_pops_up_while_gui_is_stalled(guard, qt_app, monkeypatch):
|
||||||
|
monkeypatch.setattr(crash_guard, "HANG_SECONDS", 0.6)
|
||||||
|
events = []
|
||||||
|
monkeypatch.setattr(crash_guard.HangWatchdog, "_show_popup", lambda self: events.append("show"))
|
||||||
|
monkeypatch.setattr(crash_guard.HangWatchdog, "_close_popup", lambda self: events.append("close"))
|
||||||
|
dog = crash_guard.HangWatchdog("t")
|
||||||
|
try:
|
||||||
|
qt_app.processEvents()
|
||||||
|
time.sleep(1.5) # luồng giao diện "đứng"
|
||||||
|
assert events == ["show"]
|
||||||
|
end = time.time() + 2
|
||||||
|
while "close" not in events and time.time() < end:
|
||||||
|
qt_app.processEvents() # giao diện chạy lại
|
||||||
|
time.sleep(0.05)
|
||||||
|
assert events == ["show", "close"]
|
||||||
|
finally:
|
||||||
|
dog.stop()
|
||||||
|
assert "giao diện đứng" in (guard / "hang.log").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("lang, expected", [
|
||||||
|
("vi", "Đang xử lý"), ("en", "Processing"), ("ja", "処理中"),
|
||||||
|
])
|
||||||
|
def test_popup_text_follows_the_current_app_language(qt_app, monkeypatch, lang, expected):
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
from cowork_local.i18n import get_language, set_language
|
||||||
|
|
||||||
|
shown = []
|
||||||
|
fake_user32 = types.SimpleNamespace(MessageBoxW=lambda h, msg, title, f: shown.append(msg))
|
||||||
|
monkeypatch.setattr(sys, "platform", "win32")
|
||||||
|
monkeypatch.setitem(sys.modules, "ctypes", types.SimpleNamespace(
|
||||||
|
windll=types.SimpleNamespace(user32=fake_user32)))
|
||||||
|
before = get_language()
|
||||||
|
dog = crash_guard.HangWatchdog("t")
|
||||||
|
try:
|
||||||
|
set_language(lang)
|
||||||
|
dog._show_popup()
|
||||||
|
dog._popup.join(1)
|
||||||
|
finally:
|
||||||
|
dog.stop()
|
||||||
|
set_language(before)
|
||||||
|
assert shown and expected in shown[0]
|
||||||
@@ -234,16 +234,16 @@ def test_mode_comes_from_the_workspace_when_not_pinned(ctx, app_service) -> None
|
|||||||
assert outcome.switched is True
|
assert outcome.switched is True
|
||||||
|
|
||||||
|
|
||||||
def test_fallback_mode_survives_a_round_trip_through_config(ctx) -> None:
|
def test_removed_modes_resolve_to_auto(ctx) -> None:
|
||||||
"""The new mode must be persistable, or the toggle could never select it."""
|
"""Off/Fallback were removed from the UI: a stored value resolves to Auto."""
|
||||||
ctx.config.set_routing_mode_for("cowork", "fallback")
|
for legacy in ("off", "fallback"):
|
||||||
|
ctx.config.set_routing_mode_for("cowork", legacy)
|
||||||
assert ctx.config.routing_mode_for("cowork") == "fallback"
|
assert ctx.config.routing_mode_for("cowork") == "auto"
|
||||||
assert ctx.project_routing_mode("cowork") == "fallback"
|
assert ctx.project_routing_mode("cowork") == "auto"
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_persisted_mode_degrades_to_off(ctx) -> None:
|
def test_unknown_persisted_mode_degrades_to_auto(ctx) -> None:
|
||||||
"""A hand-edited config must not enable routing by accident."""
|
"""A hand-edited config resolves to the default mode (Auto)."""
|
||||||
ctx.config.routing["surface_modes"]["cowork"] = "turbo"
|
ctx.config.routing["surface_modes"]["cowork"] = "turbo"
|
||||||
|
|
||||||
assert ctx.config.routing_mode_for("cowork") == "off"
|
assert ctx.config.routing_mode_for("cowork") == "auto"
|
||||||
|
|||||||
@@ -32,16 +32,29 @@ def _mk(ctx, name):
|
|||||||
def test_defaults_follow_global_when_no_override(ctx):
|
def test_defaults_follow_global_when_no_override(ctx):
|
||||||
a = _mk(ctx, "Alpha")
|
a = _mk(ctx, "Alpha")
|
||||||
ctx.active_project_id = a.project_id
|
ctx.active_project_id = a.project_id
|
||||||
# Global default switch_mode is "off".
|
# Global default switch_mode is "auto" (Off was removed).
|
||||||
assert ctx.project_routing_mode("cowork") == "off"
|
assert ctx.project_routing_mode("cowork") == "auto"
|
||||||
# Change the GLOBAL default → project with no override follows it.
|
# Change the GLOBAL default → project with no override follows it.
|
||||||
ctx.config.data["routing"]["switch_mode"] = "auto"
|
ctx.config.data["routing"]["switch_mode"] = "manual"
|
||||||
|
assert ctx.project_routing_mode("cowork") == "manual"
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_off_and_fallback_resolve_to_auto(ctx):
|
||||||
|
a = _mk(ctx, "Alpha")
|
||||||
|
ctx.active_project_id = a.project_id
|
||||||
|
ctx.config.data["routing"]["switch_mode"] = "manual"
|
||||||
|
for legacy in ("off", "fallback"):
|
||||||
|
a.routing_modes = {"cowork": legacy}
|
||||||
|
save_project(a)
|
||||||
|
assert ctx.project_routing_mode("cowork") == "auto"
|
||||||
|
ctx.set_project_routing_mode("cowork", "off")
|
||||||
assert ctx.project_routing_mode("cowork") == "auto"
|
assert ctx.project_routing_mode("cowork") == "auto"
|
||||||
|
|
||||||
|
|
||||||
def test_per_workspace_routing_is_isolated(ctx):
|
def test_per_workspace_routing_is_isolated(ctx):
|
||||||
a = _mk(ctx, "Alpha")
|
a = _mk(ctx, "Alpha")
|
||||||
b = _mk(ctx, "Beta")
|
b = _mk(ctx, "Beta")
|
||||||
|
ctx.config.data["routing"]["switch_mode"] = "manual"
|
||||||
|
|
||||||
ctx.active_project_id = a.project_id
|
ctx.active_project_id = a.project_id
|
||||||
ctx.set_project_routing_mode("cowork", "auto")
|
ctx.set_project_routing_mode("cowork", "auto")
|
||||||
@@ -49,16 +62,19 @@ def test_per_workspace_routing_is_isolated(ctx):
|
|||||||
|
|
||||||
# Switching to workspace B must NOT see A's override (falls back to global).
|
# Switching to workspace B must NOT see A's override (falls back to global).
|
||||||
ctx.active_project_id = b.project_id
|
ctx.active_project_id = b.project_id
|
||||||
assert ctx.project_routing_mode("cowork") == "off"
|
|
||||||
|
|
||||||
# B sets its own, independently.
|
|
||||||
ctx.set_project_routing_mode("cowork", "manual")
|
|
||||||
assert ctx.project_routing_mode("cowork") == "manual"
|
assert ctx.project_routing_mode("cowork") == "manual"
|
||||||
|
|
||||||
# A is unchanged.
|
# B sets its own, independently.
|
||||||
|
ctx.set_project_routing_mode("cowork", "auto")
|
||||||
ctx.active_project_id = a.project_id
|
ctx.active_project_id = a.project_id
|
||||||
|
ctx.set_project_routing_mode("cowork", "manual")
|
||||||
|
ctx.active_project_id = b.project_id
|
||||||
assert ctx.project_routing_mode("cowork") == "auto"
|
assert ctx.project_routing_mode("cowork") == "auto"
|
||||||
|
|
||||||
|
# A keeps its own.
|
||||||
|
ctx.active_project_id = a.project_id
|
||||||
|
assert ctx.project_routing_mode("cowork") == "manual"
|
||||||
|
|
||||||
|
|
||||||
def test_per_surface_isolated_within_a_workspace(ctx):
|
def test_per_surface_isolated_within_a_workspace(ctx):
|
||||||
a = _mk(ctx, "Alpha")
|
a = _mk(ctx, "Alpha")
|
||||||
@@ -68,7 +84,8 @@ def test_per_surface_isolated_within_a_workspace(ctx):
|
|||||||
# co4e untouched → global default.
|
# co4e untouched → global default.
|
||||||
assert ctx.project_routing_mode("cowork") == "auto"
|
assert ctx.project_routing_mode("cowork") == "auto"
|
||||||
assert ctx.project_routing_mode("ai_edit") == "manual"
|
assert ctx.project_routing_mode("ai_edit") == "manual"
|
||||||
assert ctx.project_routing_mode("co4e") == "off"
|
ctx.config.data["routing"]["switch_mode"] = "manual"
|
||||||
|
assert ctx.project_routing_mode("co4e") == "manual"
|
||||||
|
|
||||||
|
|
||||||
def test_routing_mode_persists_to_disk(ctx):
|
def test_routing_mode_persists_to_disk(ctx):
|
||||||
|
|||||||
@@ -88,12 +88,21 @@ def test_best_for_returns_strong(service):
|
|||||||
assert ranking.best.assessment.metadata.model_id == "strong-model"
|
assert ranking.best.assessment.metadata.model_id == "strong-model"
|
||||||
|
|
||||||
|
|
||||||
def test_route_off_never_switches(service):
|
def test_route_off_explicit_override_never_switches(service):
|
||||||
|
# "off" is no longer user-selectable, but the engine still honours it
|
||||||
|
# when passed explicitly.
|
||||||
|
service.reassess()
|
||||||
|
r = service.route("cowork", "Write a Python function", "anthropic", "weak-model",
|
||||||
|
mode_override="off")
|
||||||
|
assert r.mode == SwitchMode.OFF
|
||||||
|
assert r.should_switch is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_off_in_config_routes_as_auto(service):
|
||||||
service.reassess()
|
service.reassess()
|
||||||
service.ctx.config.data["routing"]["switch_mode"] = "off"
|
service.ctx.config.data["routing"]["switch_mode"] = "off"
|
||||||
r = service.route("cowork", "Write a Python function", "anthropic", "weak-model")
|
r = service.route("cowork", "Write a Python function", "anthropic", "weak-model")
|
||||||
assert r.mode == SwitchMode.OFF
|
assert r.mode == SwitchMode.AUTO
|
||||||
assert r.should_switch is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_route_auto_switches_to_strong(service):
|
def test_route_auto_switches_to_strong(service):
|
||||||
@@ -147,12 +156,12 @@ def test_route_never_raises_on_broken_store(ctx, tmp_path):
|
|||||||
|
|
||||||
def test_per_surface_mode_override(service):
|
def test_per_surface_mode_override(service):
|
||||||
service.reassess()
|
service.reassess()
|
||||||
service.ctx.config.data["routing"]["switch_mode"] = "off"
|
service.ctx.config.data["routing"]["switch_mode"] = "manual"
|
||||||
service.ctx.config.data["routing"]["surface_modes"]["co4e"] = "auto"
|
service.ctx.config.data["routing"]["surface_modes"]["co4e"] = "auto"
|
||||||
# cowork follows global (off); co4e overridden to auto
|
# cowork follows global (manual); co4e overridden to auto
|
||||||
r_cowork = service.route("cowork", "Write a Python function", "anthropic", "weak-model")
|
r_cowork = service.route("cowork", "Write a Python function", "anthropic", "weak-model")
|
||||||
r_co4e = service.route("co4e", "Write a Python function", "anthropic", "weak-model")
|
r_co4e = service.route("co4e", "Write a Python function", "anthropic", "weak-model")
|
||||||
assert r_cowork.mode == SwitchMode.OFF
|
assert r_cowork.mode == SwitchMode.MANUAL
|
||||||
assert r_co4e.mode == SwitchMode.AUTO
|
assert r_co4e.mode == SwitchMode.AUTO
|
||||||
assert r_co4e.should_switch is True
|
assert r_co4e.should_switch is True
|
||||||
|
|
||||||
|
|||||||
@@ -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 = StructureGraphView.__new__(StructureGraphView)
|
||||||
v._workspace_project = None
|
v._workspace_project = None
|
||||||
|
v._workspace_dir = None # chốt còn theo cả đường dẫn, không chỉ id
|
||||||
v.renderer = _Ghi()
|
v.renderer = _Ghi()
|
||||||
return v
|
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,42 @@
|
|||||||
|
"""Ô định tuyến chỉ còn Auto và Manual; giá trị off/fallback cũ hiện thành Auto."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
pytest.importorskip("PySide6")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def qt_app():
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
def _toggle(stored: str):
|
||||||
|
from cowork_local.ui.routing_toggle import RoutingToggle
|
||||||
|
|
||||||
|
saved = []
|
||||||
|
t = RoutingToggle(None, "cowork", get_mode=lambda: stored, set_mode=saved.append)
|
||||||
|
return t, saved
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_auto_and_manual_are_offered(qt_app):
|
||||||
|
t, _ = _toggle("auto")
|
||||||
|
items = [t._combo.itemData(i) for i in range(t._combo.count())]
|
||||||
|
assert items == ["auto", "manual"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("stored", ["off", "fallback", "", "turbo"])
|
||||||
|
def test_legacy_values_show_as_auto(qt_app, stored):
|
||||||
|
t, _ = _toggle(stored)
|
||||||
|
assert t.current_mode() == "auto"
|
||||||
|
|
||||||
|
|
||||||
|
def test_choosing_manual_persists_it(qt_app):
|
||||||
|
t, saved = _toggle("auto")
|
||||||
|
t._combo.setCurrentIndex(t._combo.findData("manual"))
|
||||||
|
assert saved == ["manual"]
|
||||||
@@ -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}"
|
||||||
+1
-1
@@ -95,7 +95,7 @@ class CoworkTab(ChatPanel):
|
|||||||
lbl = getattr(self, "_title_lbl", None)
|
lbl = getattr(self, "_title_lbl", None)
|
||||||
if lbl is None:
|
if lbl is None:
|
||||||
return # ChatPanel.__init__ sets self.title before we exist
|
return # ChatPanel.__init__ sets self.title before we exist
|
||||||
lbl.setText(getattr(self, "title", "") or tr("cowork.title"))
|
self.set_title_label(lbl, tr("cowork.title"))
|
||||||
|
|
||||||
def _retranslate(self) -> None:
|
def _retranslate(self) -> None:
|
||||||
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề và các nút trên thanh công cụ."""
|
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề và các nút trên thanh công cụ."""
|
||||||
|
|||||||
+7
-10
@@ -40,7 +40,7 @@ class RoutingToggle(QWidget):
|
|||||||
Emits :attr:`mode_changed`; call :meth:`refresh` after the workspace switches.
|
Emits :attr:`mode_changed`; call :meth:`refresh` after the workspace switches.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
mode_changed = Signal(str) # "off" | "auto" | "manual" | "fallback"
|
mode_changed = Signal(str) # "auto" | "manual"
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -76,14 +76,11 @@ class RoutingToggle(QWidget):
|
|||||||
# it the width stays frozen at the language the widget was built in and
|
# it the width stays frozen at the language the widget was built in and
|
||||||
# the longer translation is cut off.
|
# the longer translation is cut off.
|
||||||
self._combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
self._combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
||||||
# (data value, i18n key) — data is the persisted mode string. Order is
|
# (data value, i18n key) — data is the persisted mode string. Off and
|
||||||
# least-to-most autonomous, with Fallback (R03-T03) last because it is
|
# Fallback were dropped: only Auto and Manual remain selectable.
|
||||||
# the "only when something breaks" mode rather than a stronger Auto.
|
|
||||||
self._modes = [
|
self._modes = [
|
||||||
("off", "routing.mode_off"),
|
|
||||||
("auto", "routing.mode_auto"),
|
("auto", "routing.mode_auto"),
|
||||||
("manual", "routing.mode_manual"),
|
("manual", "routing.mode_manual"),
|
||||||
("fallback", "routing.mode_fallback"),
|
|
||||||
]
|
]
|
||||||
for value, key in self._modes:
|
for value, key in self._modes:
|
||||||
self._combo.addItem(tr(key), value)
|
self._combo.addItem(tr(key), value)
|
||||||
@@ -100,16 +97,16 @@ class RoutingToggle(QWidget):
|
|||||||
on_language_changed(self.retranslate)
|
on_language_changed(self.retranslate)
|
||||||
|
|
||||||
def current_mode(self) -> str:
|
def current_mode(self) -> str:
|
||||||
"""Chế độ định tuyến đang chọn; 'off' nếu chưa đặt."""
|
"""Chế độ định tuyến đang chọn; 'auto' nếu chưa đặt."""
|
||||||
return self._combo.currentData() or "off"
|
return self._combo.currentData() or "auto"
|
||||||
|
|
||||||
def refresh(self) -> None:
|
def refresh(self) -> None:
|
||||||
"""Re-read the backing mode (e.g. after switching workspace) and show it
|
"""Re-read the backing mode (e.g. after switching workspace) and show it
|
||||||
without emitting a spurious change."""
|
without emitting a spurious change."""
|
||||||
try:
|
try:
|
||||||
mode = self._get_mode() or "off"
|
mode = self._get_mode() or "auto"
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
mode = "off"
|
mode = "auto"
|
||||||
idx = self._combo.findData(mode)
|
idx = self._combo.findData(mode)
|
||||||
if idx < 0:
|
if idx < 0:
|
||||||
idx = 0
|
idx = 0
|
||||||
|
|||||||
+7
-1
@@ -14,6 +14,7 @@ from ..core.history import (
|
|||||||
rename_conversation, set_pinned,
|
rename_conversation, set_pinned,
|
||||||
)
|
)
|
||||||
from ..i18n import on_language_changed, tr
|
from ..i18n import on_language_changed, tr
|
||||||
|
from ..presentation.chat.chat_helpers import clip_chars
|
||||||
from .dialog_buttons import ask_text, confirm
|
from .dialog_buttons import ask_text, confirm
|
||||||
from .icons import collapse_left_icon, dot_icon, DOT_BLUE, icon
|
from .icons import collapse_left_icon, dot_icon, DOT_BLUE, icon
|
||||||
from .widgets import CollapseStrip
|
from .widgets import CollapseStrip
|
||||||
@@ -81,6 +82,7 @@ class HistorySidebar(QWidget):
|
|||||||
expand_requested = Signal() # strip clicked: re-expand
|
expand_requested = Signal() # strip clicked: re-expand
|
||||||
refresh_requested = Signal() # Refresh button: re-list + re-sync agent status
|
refresh_requested = Signal() # Refresh button: re-list + re-sync agent status
|
||||||
history_changed = Signal() # a conversation was deleted — other views (Project tab) should re-sync
|
history_changed = Signal() # a conversation was deleted — other views (Project tab) should re-sync
|
||||||
|
conversation_renamed = Signal(str, str) # session_id, new title — the open chat retitles itself
|
||||||
|
|
||||||
def __init__(self, ctx: AppContext):
|
def __init__(self, ctx: AppContext):
|
||||||
"""Cột lịch sử hội thoại.
|
"""Cột lịch sử hội thoại.
|
||||||
@@ -273,7 +275,9 @@ class HistorySidebar(QWidget):
|
|||||||
is_current = bool(sid) and sid == self.current_session_id
|
is_current = bool(sid) and sid == self.current_session_id
|
||||||
is_running = sid in self.running_ids
|
is_running = sid in self.running_ids
|
||||||
suffix = tr("sidebar.running_suffix") if is_running else ""
|
suffix = tr("sidebar.running_suffix") if is_running else ""
|
||||||
item = QTreeWidgetItem([f"{title}{suffix}\n{created}"])
|
# Tối đa 10 ký tự như tiêu đề khung chat; tên đầy đủ ở tooltip.
|
||||||
|
item = QTreeWidgetItem([f"{clip_chars(title)}{suffix}\n{created}"])
|
||||||
|
item.setToolTip(0, title)
|
||||||
# A running turn (blue LED) takes visual priority over the pin icon.
|
# A running turn (blue LED) takes visual priority over the pin icon.
|
||||||
if is_running:
|
if is_running:
|
||||||
item.setIcon(0, dot_icon(DOT_BLUE))
|
item.setIcon(0, dot_icon(DOT_BLUE))
|
||||||
@@ -352,6 +356,8 @@ class HistorySidebar(QWidget):
|
|||||||
if ok and new.strip():
|
if ok and new.strip():
|
||||||
rename_conversation(path, new.strip())
|
rename_conversation(path, new.strip())
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
sid = load_conversation(path).get("session_id", "")
|
||||||
|
self.conversation_renamed.emit(sid, new.strip())
|
||||||
elif chosen == del_act:
|
elif chosen == del_act:
|
||||||
if confirm(self, tr("sidebar.delete.title"),
|
if confirm(self, tr("sidebar.delete.title"),
|
||||||
tr("sidebar.delete.confirm", title=title)):
|
tr("sidebar.delete.confirm", title=title)):
|
||||||
|
|||||||
@@ -339,6 +339,8 @@ class WorkspaceTab(ProjectEditingMixin, ProjectFolderRuleMixin, QWidget):
|
|||||||
sb.expand_requested.connect(lambda: self._on_history_fold(False))
|
sb.expand_requested.connect(lambda: self._on_history_fold(False))
|
||||||
sb.refresh_requested.connect(self._on_sidebar_refresh)
|
sb.refresh_requested.connect(self._on_sidebar_refresh)
|
||||||
sb.history_changed.connect(self._reload_threads)
|
sb.history_changed.connect(self._reload_threads)
|
||||||
|
if self._cowork is not None:
|
||||||
|
sb.conversation_renamed.connect(self._cowork.apply_renamed_title)
|
||||||
|
|
||||||
def _on_history_fold(self, collapsed: bool) -> None:
|
def _on_history_fold(self, collapsed: bool) -> None:
|
||||||
"""Its chevron closes the panel away, back to the drawn layout."""
|
"""Its chevron closes the panel away, back to the drawn layout."""
|
||||||
@@ -947,6 +949,7 @@ class WorkspaceTab(ProjectEditingMixin, ProjectFolderRuleMixin, QWidget):
|
|||||||
project.output_dir = chosen
|
project.output_dir = chosen
|
||||||
save_project(project)
|
save_project(project)
|
||||||
self.folder_lbl.setText(chosen)
|
self.folder_lbl.setText(chosen)
|
||||||
|
self.rebind_workspace_folder()
|
||||||
self.status_message.emit(tr("workspace.saved", name=project.name))
|
self.status_message.emit(tr("workspace.saved", name=project.name))
|
||||||
|
|
||||||
def _open_workspace(self) -> None:
|
def _open_workspace(self) -> None:
|
||||||
@@ -1002,6 +1005,7 @@ class WorkspaceTab(ProjectEditingMixin, ProjectFolderRuleMixin, QWidget):
|
|||||||
project.cloud_source = cloud_source
|
project.cloud_source = cloud_source
|
||||||
save_project(project)
|
save_project(project)
|
||||||
self.folder_lbl.setText(str(local_dir))
|
self.folder_lbl.setText(str(local_dir))
|
||||||
|
self.rebind_workspace_folder()
|
||||||
self._refresh_cloud_badge(project)
|
self._refresh_cloud_badge(project)
|
||||||
self.projects_changed.emit()
|
self.projects_changed.emit()
|
||||||
if report.errors:
|
if report.errors:
|
||||||
|
|||||||
Reference in New Issue
Block a user