Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6b68a0bf8 | ||
|
|
0b6b220bd9 | ||
|
|
fefc9f94db | ||
|
|
ddb31f9aff | ||
|
|
b500b3e57d | ||
|
|
76225aa118 | ||
|
|
35334274eb | ||
|
|
10b8379824 |
@@ -72,6 +72,12 @@ def run(argv: List[str] | None = None) -> int:
|
||||
app = QApplication.instance() or QApplication(argv)
|
||||
app.setApplicationName(APP_NAME)
|
||||
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
|
||||
# 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.
|
||||
@@ -132,4 +138,10 @@ def run(argv: List[str] | None = None) -> int:
|
||||
pass
|
||||
|
||||
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()
|
||||
|
||||
@@ -236,7 +236,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
|
||||
# file (~/.cowork_local/assessments.json + assessments_history/), not here —
|
||||
# this section is only the behaviour config the user edits.
|
||||
"routing": {
|
||||
"switch_mode": "off", # global default: "off" | "auto" | "manual"
|
||||
"switch_mode": "auto", # global default: "auto" | "manual"
|
||||
"policy": "balanced", # "quality" | "cost" | "latency" | "balanced"
|
||||
"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
|
||||
@@ -246,7 +246,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
|
||||
"judge_model": "", # fixed cheap judge model ("" → a per-provider default)
|
||||
"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
|
||||
# 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".
|
||||
"surface_modes": {
|
||||
"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
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
@@ -33,6 +34,45 @@ def new_session_id() -> str:
|
||||
return datetime.now().strftime("%Y%m%d-%H%M%S-%f")[:-3]
|
||||
|
||||
|
||||
#: Độ dài mong muốn của một tiêu đề hội thoại, tính bằng ký tự.
|
||||
TITLE_MAX_CHARS = 60
|
||||
#: Số ký tự được phép vượt ``TITLE_MAX_CHARS`` để viết nốt từ đang bị cắt dở.
|
||||
#: Cỡ một từ tiếng Việt — đủ để cứu chữ cuối, không đủ để kéo dài tiêu đề.
|
||||
_TITLE_SLACK = 12
|
||||
|
||||
_KHOANG_TRANG = re.compile(r"\s")
|
||||
|
||||
|
||||
def shorten_title(text: str, limit: int = TITLE_MAX_CHARS) -> str:
|
||||
"""Rút gọn tiêu đề mà KHÔNG cắt vào giữa một từ.
|
||||
|
||||
Cắt cứng ở ký tự thứ ``limit`` đọc rất khó chịu khi mốc đó rơi vào giữa từ:
|
||||
"…tóm tắt từng tệp" thành "…tóm tắt từng tệ…" — trông như lỗi gõ chứ không
|
||||
như một câu bị rút gọn. Nên khi mốc cắt rơi vào giữa từ thì viết nốt từ đó.
|
||||
|
||||
Ba lối ra, theo thứ tự ưu tiên:
|
||||
|
||||
* Viết nốt từ đang dở, nếu chỉ phải vượt thêm tối đa ``_TITLE_SLACK`` ký tự.
|
||||
Viết nốt mà vừa hết chuỗi thì **không** thêm dấu ba chấm — không còn chữ
|
||||
nào bị bỏ thì dấu ba chấm là nói dối.
|
||||
* Từ dài bất thường (đường dẫn, URL) thì lùi về ranh giới từ ngay trước mốc,
|
||||
để một token dài không kéo tiêu đề dài ra tuỳ ý.
|
||||
* Cả tiêu đề chỉ là một từ dài thì đành cắt cứng — không còn ranh giới nào.
|
||||
"""
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
if text[limit].isspace(): # mốc cắt vốn đã nằm giữa hai từ
|
||||
return text[:limit].rstrip() + "…"
|
||||
sau = _KHOANG_TRANG.search(text, limit)
|
||||
het_tu = sau.start() if sau is not None else len(text)
|
||||
if het_tu - limit <= _TITLE_SLACK:
|
||||
return text if het_tu == len(text) else text[:het_tu] + "…"
|
||||
truoc = [m.start() for m in _KHOANG_TRANG.finditer(text, 0, limit)]
|
||||
if truoc:
|
||||
return text[:truoc[-1]] + "…"
|
||||
return text[:limit] + "…"
|
||||
|
||||
|
||||
def derive_title(messages: List[Dict[str, Any]]) -> str:
|
||||
"""Suy tiêu đề hội thoại từ tin nhắn đầu tiên của người dùng.
|
||||
|
||||
@@ -40,8 +80,7 @@ def derive_title(messages: List[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
for m in messages:
|
||||
if m.get("role") == "user" and m.get("content"):
|
||||
text = " ".join(m["content"].split())
|
||||
return text[:60] + ("…" if len(text) > 60 else "")
|
||||
return shorten_title(" ".join(m["content"].split()))
|
||||
return "(empty)"
|
||||
|
||||
|
||||
|
||||
@@ -75,26 +75,16 @@ class RoutingScheduler(QObject):
|
||||
return None
|
||||
|
||||
def _routing_enabled_anywhere(self) -> bool:
|
||||
"""Is routing actually in use? True if the global mode is auto/manual OR
|
||||
any chat surface overrides to auto/manual. When everything is Off, the
|
||||
assessment scores would never be consulted — so we don't spend tokens
|
||||
probing for them (no surprise cost on a fresh install)."""
|
||||
try:
|
||||
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
|
||||
"""Is routing actually in use? Always, now: the Off mode was removed and
|
||||
every stored value resolves to Auto or Manual (see
|
||||
``config.user_routing_mode``). Paid probing is switched off through
|
||||
``reassess_interval_hours = 0`` instead."""
|
||||
return True
|
||||
|
||||
def is_due(self) -> bool:
|
||||
"""Đã đế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í,
|
||||
không được tiêu tiền cho một tính năng người dùng đã tắt.
|
||||
Dò model là lượt gọi có tính phí: đặt chu kỳ chấm lại = 0 thì không dò.
|
||||
"""
|
||||
if not self._routing_enabled_anywhere():
|
||||
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.done": {"en": "{name}: done.", "ja": "{name}: 完了。", "vi": "{name}: xong."},
|
||||
"chatpanel.failed": {"en": "{name}: error.", "ja": "{name}: エラー。", "vi": "{name}: lỗi."},
|
||||
"chatpanel.stopped": {"en": "{name}: stopped.", "ja": "{name}: 停止しました。",
|
||||
"vi": "{name}: đã dừng."},
|
||||
"chatpanel.stopping": {"en": "{name}: stopping…", "ja": "{name}: 停止中…", "vi": "{name}: đang dừng…"},
|
||||
"chatpanel.attach_limit": {
|
||||
"en": "Max {n} attachments — extra files were skipped.",
|
||||
|
||||
@@ -155,6 +155,12 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"app.lang.switching": {
|
||||
"en": "Switching language…", "ja": "言語を切り替えています…",
|
||||
"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.schedule": {"en": "Schedule Task", "ja": "タスクスケジュール", "vi": "Schedule Task"},
|
||||
"app.tab.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
|
||||
|
||||
@@ -10,9 +10,9 @@ from typing import Dict
|
||||
|
||||
STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"settings.sandbox_block_network": {
|
||||
"en": "Block network (AI provider still allowed)",
|
||||
"ja": "ネットワークをブロック(AIプロバイダーのみ許可)",
|
||||
"vi": "Chặn mạng (vẫn cho gọi nhà cung cấp AI)"},
|
||||
"en": "Block network for agent-run commands",
|
||||
"ja": "エージェントが実行するコマンドのネットワークをブロック",
|
||||
"vi": "Chặn mạng cho lệnh do agent chạy"},
|
||||
"settings.allow_url_fetch": {
|
||||
"en": "Allow the agent to fetch URLs (web pages, SharePoint / OneDrive links)",
|
||||
"ja": "エージェントによるURL取得を許可(Webページ、SharePoint / OneDriveリンク)",
|
||||
|
||||
@@ -229,6 +229,11 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"chat.open_folder": {"en": "Open folder", "ja": "フォルダを開く", "vi": "Mở thư mục"},
|
||||
"chat.open_output_folder": {"en": "Open output folder", "ja": "出力フォルダを開く", "vi": "Mở thư mục output"},
|
||||
"chat.done_marker": {"en": "Done", "ja": "完了しました", "vi": "Đã hoàn thành"},
|
||||
"chat.stopping": {"en": "Stopping", "ja": "停止中", "vi": "Đang dừng"},
|
||||
"chat.stopped_marker": {
|
||||
"en": "⏹ Stopped at your request",
|
||||
"ja": "⏹ リクエストにより停止しました",
|
||||
"vi": "⏹ Đã dừng theo yêu cầu"},
|
||||
"chat.session_folder_marker": {
|
||||
"en": "This conversation's output folder", "ja": "この会話の出力フォルダ",
|
||||
"vi": "Thư mục output của hội thoại này"},
|
||||
|
||||
@@ -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
|
||||
#: ``config.py`` để hai bên lệch nhau là đỏ ngay.
|
||||
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
|
||||
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.
|
||||
|
||||
Đặ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
|
||||
có người sửa tay file cấu hình."""
|
||||
Chỉ còn Auto/Manual: "off", "fallback" cũ hay giá trị lạ đều hiểu là "auto"."""
|
||||
routing = self.routing
|
||||
override = (routing.get("surface_modes", {}) or {}).get(surface, "")
|
||||
mode = override or routing.get("switch_mode", "off")
|
||||
return mode if mode in self.ROUTING_MODES else "off"
|
||||
return self.user_routing_mode(override or routing.get("switch_mode", ""))
|
||||
|
||||
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."""
|
||||
mode = mode if mode in self.ROUTING_MODES else "off"
|
||||
mode = self.user_routing_mode(mode)
|
||||
self.routing.setdefault("surface_modes", {})[surface] = mode
|
||||
self.save()
|
||||
|
||||
|
||||
@@ -9,6 +9,13 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
_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:
|
||||
|
||||
@@ -99,6 +99,9 @@ class ChatPanel(ChatLiveTurnsMixin, ChatPanelLayoutMixin, ChatEventStreamMixin,
|
||||
# can run concurrently. Each value is a turn-context dict — see _start_turn.
|
||||
self.worker: AgentWorker | None = None
|
||||
self._active: Dict[AgentWorker, Dict[str, Any]] = {}
|
||||
# Người dùng đã bấm Dừng cho lượt đang chạy chưa. Cờ này chỉ đổi thứ
|
||||
# MÀN HÌNH nói, không đổi việc huỷ: huỷ vẫn là cờ trên worker.
|
||||
self._stop_requested = False
|
||||
self._turn_seq: int = 0
|
||||
# session_id -> its live messages list, for every conversation that still has
|
||||
# a turn running. Lets you start a new chat / reopen an old one WHILE work
|
||||
@@ -337,7 +340,10 @@ class ChatPanel(ChatLiveTurnsMixin, ChatPanelLayoutMixin, ChatEventStreamMixin,
|
||||
Switching chats, or hitting History → Refresh, shows whether THIS chat is
|
||||
still processing (a background turn) or idle."""
|
||||
if self._view_busy():
|
||||
self.thinking.start("chat.running") # this conversation is still working
|
||||
# Đã bấm Dừng mà lượt chưa kết thúc: giữ nhãn "đang dừng", đừng kéo
|
||||
# ngược về "đang chạy" — người dùng vừa bấm xong mà thấy chữ cũ thì
|
||||
# đọc ra là nút không ăn.
|
||||
self.thinking.start("chat.stopping" if self._stop_requested else "chat.running")
|
||||
else:
|
||||
self.thinking.stop()
|
||||
self.composer.set_running(bool(self._active)) # Stop shows while anything runs
|
||||
|
||||
@@ -224,6 +224,23 @@ class ChatSessionMixin:
|
||||
if getattr(self, "_usage_total_lbl", None) is not None:
|
||||
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:
|
||||
"""Switch the view to a stored conversation. Allowed while work is running —
|
||||
the current turns keep going in the background."""
|
||||
|
||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from ...core.history import shorten_title
|
||||
from ...core.worker import AgentWorker
|
||||
from ...i18n import tr
|
||||
from ...state import AppContext
|
||||
@@ -88,7 +89,11 @@ class ChatTurnRunnerMixin:
|
||||
prefix = f"{prefix}\n\n{agent_prefix}" if prefix else agent_prefix
|
||||
if not self.title:
|
||||
base = text or (Path(attachments[0]).name if attachments else "(attachment)")
|
||||
self.title = (base[:60] + "…") if len(base) > 60 else base
|
||||
# Rút gọn mà không cắt vào giữa từ: xem shorten_title trong
|
||||
# core/history.py. Dùng chung với derive_title để tiêu đề trên
|
||||
# thanh tiêu đề và tiêu đề lưu vào lịch sử không rút gọn theo
|
||||
# hai kiểu khác nhau.
|
||||
self.title = shorten_title(base)
|
||||
self._notify_title()
|
||||
|
||||
# Reset the Plan panel so each message starts from a clean checklist (the
|
||||
@@ -212,6 +217,7 @@ class ChatTurnRunnerMixin:
|
||||
if self._view_busy() or len(self._active) >= self._max_parallel():
|
||||
self.composer.set_busy(True)
|
||||
self.status_message.emit(tr("chatpanel.working", name=tr(f"app.tab.{self.kind}")))
|
||||
self._stop_requested = False # lượt mới: xoá dấu vết lần Dừng trước
|
||||
self.thinking.start("chat.running")
|
||||
worker.start()
|
||||
|
||||
@@ -254,7 +260,7 @@ class ChatTurnRunnerMixin:
|
||||
self._show_usage(ctx) # per-turn + conversation token/cost
|
||||
except Exception: # noqa: BLE001 — usage display must never break a turn
|
||||
pass
|
||||
done = self.chat_view.add_success(tr("chat.done_marker")) # green done marker in the chat box
|
||||
done = self._dau_ket_thuc()
|
||||
folder = self.workspace_dir()
|
||||
if folder:
|
||||
done.add_folder_link(str(folder), tr("chat.open_output_folder"))
|
||||
@@ -268,12 +274,32 @@ class ChatTurnRunnerMixin:
|
||||
self._maybe_notify_teams(result)
|
||||
self._drain_queue()
|
||||
|
||||
def _dau_ket_thuc(self):
|
||||
"""Dấu kết thúc đặt vào khung chat khi một lượt vừa xong.
|
||||
|
||||
Dừng theo yêu cầu KHÔNG phải là hoàn thành: dấu xanh "Đã hoàn thành" ở
|
||||
đó nói ngược hẳn với thứ người dùng vừa làm, và là lý do người dùng báo
|
||||
"bấm Dừng mà không biết nó đã dừng hay chưa".
|
||||
|
||||
Tách khỏi ``_on_finished`` để nhánh này kiểm được bằng test mà không
|
||||
phải dựng cả một ``ChatPanel``.
|
||||
"""
|
||||
if self._stop_requested:
|
||||
return self.chat_view.add_status(tr("chat.stopped_marker"))
|
||||
return self.chat_view.add_success(tr("chat.done_marker"))
|
||||
|
||||
def _on_failed(self, ctx: Dict[str, Any], err: str) -> None:
|
||||
"""Lượt chạy lỗi: huỷ thư mục kết quả tạm và hiện lỗi (nếu hội thoại còn đang mở)."""
|
||||
live = self._turn_is_live(ctx)
|
||||
self._end_turn(ctx)
|
||||
self._cleanup_turn(ctx, False) # discard this turn's output sandbox
|
||||
if live:
|
||||
if live and self._stop_requested:
|
||||
# Người dùng vừa bấm Dừng: mọi lỗi phát sinh trong lúc huỷ là hệ quả
|
||||
# của chính việc huỷ (đóng socket giữa stream, tool bị cắt ngang).
|
||||
# Dội một traceback đỏ vào mặt họ là trả lời sai câu hỏi "nó dừng
|
||||
# chưa?" — thứ họ cần là một dòng nói rõ là đã dừng.
|
||||
self._dau_ket_thuc()
|
||||
elif live:
|
||||
self.chat_view.add_error(err)
|
||||
self.graph_event.emit(self.session_name, {"type": "error", "content": err})
|
||||
from ...providers.base import is_model_not_found_error
|
||||
@@ -286,7 +312,10 @@ class ChatTurnRunnerMixin:
|
||||
self.composer.set_text(ctx["display_text"])
|
||||
else:
|
||||
self._persist_session(ctx)
|
||||
self.status_message.emit(tr("chatpanel.failed", name=tr(f"app.tab.{self.kind}")))
|
||||
# Thanh trạng thái phải nói cùng một chuyện với khung chat: dừng theo
|
||||
# yêu cầu thì không phải "lỗi".
|
||||
key = "chatpanel.stopped" if self._stop_requested else "chatpanel.failed"
|
||||
self.status_message.emit(tr(key, name=tr(f"app.tab.{self.kind}")))
|
||||
self.turn_finished.emit({"error": err})
|
||||
self._drain_queue()
|
||||
|
||||
@@ -311,6 +340,11 @@ class ChatTurnRunnerMixin:
|
||||
"""Dừng mọi lượt đang chạy của hội thoại này và xoá sạch hàng đợi."""
|
||||
if not self._active:
|
||||
return
|
||||
# Báo NGAY trên màn: huỷ thật có thể mất vài giây (đang chờ gateway trả
|
||||
# lời, đang chạy dở một tool), mà trong lúc đó chỉ báo vẫn đếm "Đang
|
||||
# chạy · 160s" — người dùng đọc ra là nút Dừng không ăn.
|
||||
self._stop_requested = True
|
||||
self.thinking.set_label("chat.stopping")
|
||||
for w in list(self._active):
|
||||
if w.isRunning():
|
||||
w.request_stop()
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Khoá phạm vi quét của màn GraphRAG vào một project.
|
||||
|
||||
Tách khỏi ``graph_renderer.py``: file đó đã ở 399/400 dòng — đúng một dòng
|
||||
trước trần của ``scripts/check_loc.py``, và cổng ấy nói rõ cách duy nhất đúng
|
||||
khi chạm trần là tách file, không phải nới con số. Khối này là chỗ tự nhiên
|
||||
để cắt: ba phương thức dưới đây chỉ nói về một việc — project nào đang khoá,
|
||||
và thư mục nào đi theo nó — còn phần còn lại của renderer lo việc vẽ.
|
||||
|
||||
Là mixin chứ không phải đối tượng rời, cùng lý do như ``NavRailMixin``: ba
|
||||
phương thức này đọc/ghi state của chính renderer (``project_combo``,
|
||||
``path_edit``, ``_needs_scan``…). Biến thành đối tượng cộng tác thì phải viết
|
||||
lại từng chỗ ``self.X`` thành ``self.renderer.X`` mà không đổi hành vi gì.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.i18n import tr
|
||||
|
||||
|
||||
class GraphProjectLockMixin:
|
||||
"""Ba phương thức khoá-theo-project. Trộn vào ``GraphRenderer``."""
|
||||
|
||||
def _refresh_project_combo(self) -> None:
|
||||
"""Nạp lại danh sách project vào bộ chọn, giữ nguyên project đang chọn."""
|
||||
from cowork_local.core.projects import list_projects
|
||||
|
||||
keep = self._active_project_id
|
||||
self.project_combo.blockSignals(True)
|
||||
self.project_combo.clear()
|
||||
self.project_combo.addItem(tr("structure.project_none"), "")
|
||||
row_to_select = 0
|
||||
for i, p in enumerate(list_projects(), start=1):
|
||||
self.project_combo.addItem(p.name, p.project_id)
|
||||
if p.project_id == keep:
|
||||
row_to_select = i
|
||||
self.project_combo.setCurrentIndex(row_to_select)
|
||||
self.project_combo.blockSignals(False)
|
||||
|
||||
def set_project(self, project_id: str) -> None:
|
||||
"""Khoá phạm vi quét vào một project (chuỗi rỗng là bỏ khoá)."""
|
||||
pid = project_id or ""
|
||||
self._refresh_project_combo()
|
||||
target = self.project_combo.findData(pid)
|
||||
if target < 0:
|
||||
target = 0
|
||||
if self.project_combo.currentIndex() == target:
|
||||
self._on_project_changed(target)
|
||||
else:
|
||||
self.project_combo.setCurrentIndex(target)
|
||||
|
||||
def _on_project_changed(self, _idx: int) -> None:
|
||||
"""Áp trạng thái khoá: đường dẫn chuyển sang chỉ đọc và trỏ vào thư mục"""
|
||||
from cowork_local.core.projects import load_project
|
||||
|
||||
pid = self.project_combo.currentData() or ""
|
||||
project_changed = pid != self._active_project_id
|
||||
self._active_project_id = pid
|
||||
locked = bool(pid)
|
||||
self.path_edit.setReadOnly(locked)
|
||||
self._pick_btn.setEnabled(not locked)
|
||||
if locked:
|
||||
project = load_project(pid)
|
||||
if project is not None:
|
||||
self.path_edit.setText(str(project.workspace_dir()))
|
||||
# Changing the FOLDER changes what we scan just as much as changing the
|
||||
# project does. Keying this off the id alone left the path in the bar
|
||||
# updated while the graph in the middle still showed the old folder's
|
||||
# nodes: "Đổi" in the Project screen moves the folder, never the id.
|
||||
duong_dan = self.path_edit.text().strip()
|
||||
doi_muc_tieu = project_changed or duong_dan != self._active_path
|
||||
self._active_path = duong_dan
|
||||
if doi_muc_tieu:
|
||||
self.project_changed.emit() # GraphQaWidget drops its temp extraction cache
|
||||
# Mark it and scan on the next visit rather than now — see
|
||||
# auto_scan_and_fit()'s docstring for why.
|
||||
self._needs_scan = True
|
||||
# ...except when this screen is the one on show. The picker lives HERE,
|
||||
# so a user changing project is already looking at the graph: there is
|
||||
# no "next visit" to defer to, and they had to press Scan by hand.
|
||||
# Deferring still applies when the change came from the Workspace
|
||||
# screen while this one is hidden, which is what it was for.
|
||||
if self.isVisible() and duong_dan:
|
||||
self._needs_scan = False
|
||||
self._scan()
|
||||
@@ -27,6 +27,7 @@ from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.graph import graph_export
|
||||
from cowork_local.presentation.graph.graph_messages_view import GraphMessagesView
|
||||
from cowork_local.presentation.graph.graph_project_lock import GraphProjectLockMixin
|
||||
from cowork_local.presentation.graph.graph_scene_builder import build_scene
|
||||
from cowork_local.presentation.graph.graph_scene_items import _Bridge, _Edge, _GraphView, _Node
|
||||
from cowork_local.presentation.shared import HAS_WEB_ENGINE
|
||||
@@ -35,7 +36,7 @@ from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
class GraphRenderer(QWidget):
|
||||
class GraphRenderer(GraphProjectLockMixin, QWidget):
|
||||
"""Nửa "đồ thị" của màn GraphRAG: thanh công cụ, khung xem và vòng đời quét."""
|
||||
status_message = Signal(str)
|
||||
node_selected = Signal(object) # a node's .data, whenever the scene selection changes
|
||||
@@ -60,6 +61,8 @@ class GraphRenderer(QWidget):
|
||||
self._needs_scan = False
|
||||
self._scan_seq = 0 # only the latest scan's result is rendered (no stale overwrite)
|
||||
self._active_project_id = "" # "" = free path; set = scan locked to that project's sandbox
|
||||
# The folder last scanned — see graph_project_lock.py.
|
||||
self._active_path = ""
|
||||
|
||||
self._rescan_timer = QTimer(self)
|
||||
self._rescan_timer.setSingleShot(True)
|
||||
@@ -154,63 +157,6 @@ class GraphRenderer(QWidget):
|
||||
"""
|
||||
return [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)]
|
||||
|
||||
# ---- project sandbox lock ------------------------------------------------- #
|
||||
def _refresh_project_combo(self) -> None:
|
||||
"""Nạp lại danh sách project vào bộ chọn, giữ nguyên project đang chọn."""
|
||||
from cowork_local.core.projects import list_projects
|
||||
|
||||
keep = self._active_project_id
|
||||
self.project_combo.blockSignals(True)
|
||||
self.project_combo.clear()
|
||||
self.project_combo.addItem(tr("structure.project_none"), "")
|
||||
row_to_select = 0
|
||||
for i, p in enumerate(list_projects(), start=1):
|
||||
self.project_combo.addItem(p.name, p.project_id)
|
||||
if p.project_id == keep:
|
||||
row_to_select = i
|
||||
self.project_combo.setCurrentIndex(row_to_select)
|
||||
self.project_combo.blockSignals(False)
|
||||
|
||||
def set_project(self, project_id: str) -> None:
|
||||
"""Khoá phạm vi quét vào một project (chuỗi rỗng là bỏ khoá)."""
|
||||
pid = project_id or ""
|
||||
self._refresh_project_combo()
|
||||
target = self.project_combo.findData(pid)
|
||||
if target < 0:
|
||||
target = 0
|
||||
if self.project_combo.currentIndex() == target:
|
||||
self._on_project_changed(target)
|
||||
else:
|
||||
self.project_combo.setCurrentIndex(target)
|
||||
|
||||
def _on_project_changed(self, _idx: int) -> None:
|
||||
"""Áp trạng thái khoá: đường dẫn chuyển sang chỉ đọc và trỏ vào thư mục"""
|
||||
from cowork_local.core.projects import load_project
|
||||
|
||||
pid = self.project_combo.currentData() or ""
|
||||
project_changed = pid != self._active_project_id
|
||||
self._active_project_id = pid
|
||||
locked = bool(pid)
|
||||
self.path_edit.setReadOnly(locked)
|
||||
self._pick_btn.setEnabled(not locked)
|
||||
if locked:
|
||||
project = load_project(pid)
|
||||
if project is not None:
|
||||
self.path_edit.setText(str(project.workspace_dir()))
|
||||
if project_changed:
|
||||
self.project_changed.emit() # GraphQaWidget drops its temp extraction cache
|
||||
# Mark it and scan on the next visit rather than now — see
|
||||
# auto_scan_and_fit()'s docstring for why.
|
||||
self._needs_scan = True
|
||||
# ...except when this screen is the one on show. The picker lives HERE,
|
||||
# so a user changing project is already looking at the graph: there is
|
||||
# no "next visit" to defer to, and they had to press Scan by hand.
|
||||
# Deferring still applies when the change came from the Workspace
|
||||
# screen while this one is hidden, which is what it was for.
|
||||
if self.isVisible() and self.path_edit.text().strip():
|
||||
self._needs_scan = False
|
||||
self._scan()
|
||||
|
||||
# ---- helpers ---------------------------------------------------------------- #
|
||||
def _pick(self) -> None:
|
||||
"""Mở hộp thoại chọn thư mục gốc để quét."""
|
||||
|
||||
@@ -40,6 +40,9 @@ class StructureGraphView(QWidget):
|
||||
# Project ma man Workspace da ap xuong lan gan nhat. None = chua ap lan
|
||||
# nao, de lan goi dau tien khong bi bo qua ke ca khi pid la chuoi rong.
|
||||
self._workspace_project = None
|
||||
# Thư mục của project đó lúc áp gần nhất. Chốt theo CẢ đường dẫn chứ
|
||||
# không chỉ theo id — xem set_workspace_project.
|
||||
self._workspace_dir = None
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
self.renderer = GraphRenderer(ctx)
|
||||
@@ -208,13 +211,29 @@ class StructureGraphView(QWidget):
|
||||
|
||||
Đổi sang project khác ở màn Workspace thì vẫn áp — cùng luật với tab Thư
|
||||
mục (``FolderTab.set_project_root``). Chỉ lần refresh trong CÙNG một
|
||||
project là không được đụng.
|
||||
project VÀ cùng một thư mục là không được đụng.
|
||||
|
||||
Chốt theo cả đường dẫn chứ không chỉ theo id: đổi thư mục làm việc ở
|
||||
màn Project không làm id đổi, nên chốt theo mỗi id thì màn này giữ
|
||||
nguyên đường dẫn cũ và quét nhầm thư mục. Tab Thư mục vốn đã chốt theo
|
||||
đường dẫn — đây là đưa hai nơi về đúng cùng một luật như comment này
|
||||
vẫn nói.
|
||||
"""
|
||||
if project_id == self._workspace_project:
|
||||
thu_muc = self._thu_muc_cua(project_id)
|
||||
if project_id == self._workspace_project and thu_muc == self._workspace_dir:
|
||||
return
|
||||
self._workspace_project = project_id
|
||||
self._workspace_dir = thu_muc
|
||||
self.set_project(project_id)
|
||||
|
||||
@staticmethod
|
||||
def _thu_muc_cua(project_id: str) -> str:
|
||||
"""Thư mục làm việc hiện tại của project, chuỗi rỗng nếu không có."""
|
||||
from cowork_local.core.projects import load_project
|
||||
|
||||
project = load_project(project_id) if project_id else None
|
||||
return str(project.workspace_dir()) if project is not None else ""
|
||||
|
||||
def prewarm(self) -> None:
|
||||
"""Dựng sẵn khung đồ thị trước khi người dùng bấm vào, để lần mở đầu không giật."""
|
||||
self.renderer.prewarm()
|
||||
|
||||
@@ -12,11 +12,9 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from ...i18n import tr
|
||||
|
||||
#: Các chế độ định tuyến. Danh sách này phải khớp ``config.py::AppConfig
|
||||
#: .ROUTING_MODES`` — Delta thêm "fallback" ở R03-T03 và nếu quên đồng bộ
|
||||
#: 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 = (("off", "routing.mode_off"), ("auto", "routing.mode_auto"),
|
||||
("manual", "routing.mode_manual"))
|
||||
#: Các chế độ người dùng chọn được — khớp ``AppConfig.USER_ROUTING_MODES``.
|
||||
#: Off/Fallback đã bỏ; giá trị cũ còn lưu được hiểu là Auto.
|
||||
MODE_KEYS = (("auto", "routing.mode_auto"), ("manual", "routing.mode_manual"))
|
||||
|
||||
POLICY_KEYS = (("quality", "routing.policy_quality"), ("cost", "routing.policy_cost"),
|
||||
("latency", "routing.policy_latency"),
|
||||
@@ -37,7 +35,7 @@ class RoutingSettingsWidget(QGroupBox):
|
||||
self.mode = QComboBox()
|
||||
for value, key in MODE_KEYS:
|
||||
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)
|
||||
|
||||
self.policy = QComboBox()
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Width of a navigation list whose current row is drawn bold.
|
||||
|
||||
The theme draws the selected ``sectionIndex`` row with ``font-weight: 600``
|
||||
and 10px padding each side (``theme/qss.py``). Sizing the list from the plain
|
||||
font left the selected label too wide for its row, so "Sandbox Security
|
||||
Layer" was cut off in Settings as soon as it was picked.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from PySide6.QtGui import QFont, QFontMetrics
|
||||
|
||||
#: Item padding (10px x 2) + item radius + list frame, with a little slack.
|
||||
ROW_CHROME_PX = 48
|
||||
|
||||
|
||||
def selected_label_width(widget, labels: Iterable[str]) -> int:
|
||||
"""Pixels needed to show the widest of ``labels`` in the bold selected style."""
|
||||
widget.ensurePolished()
|
||||
bold = QFont(widget.font())
|
||||
bold.setWeight(QFont.Weight.DemiBold)
|
||||
metrics = QFontMetrics(bold)
|
||||
return max((metrics.horizontalAdvance(label) for label in labels), default=0) + ROW_CHROME_PX
|
||||
|
||||
|
||||
__all__ = ["ROW_CHROME_PX", "selected_label_width"]
|
||||
@@ -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.project_selected.connect(self._sync_folder_warning)
|
||||
|
||||
def rebind_workspace_folder(self) -> None:
|
||||
"""Thư mục làm việc vừa đổi — trỏ lại những màn đang bám vào nó.
|
||||
|
||||
Đổi thư mục KHÔNG làm project id đổi, nên không có gì trong luồng
|
||||
chọn project chạy lại: ``_pick_folder`` ghi ``output_dir`` rồi dừng.
|
||||
Tab Thư mục và màn GraphRAG vì thế giữ nguyên đường dẫn cũ — tên
|
||||
project vẫn đúng nên nhìn qua tưởng ổn, nhưng GraphRAG quét nhầm
|
||||
thư mục.
|
||||
|
||||
Cố ý KHÔNG gọi ``_load_current``: hàm đó nạp lại cả biểu mẫu từ đĩa,
|
||||
nên gọi nó lúc người dùng đang sửa dở Tên/Mô tả là xoá mất phần chưa
|
||||
lưu.
|
||||
"""
|
||||
from ...core.projects import load_project
|
||||
|
||||
pid = getattr(self, "_current_id", "")
|
||||
project = load_project(pid) if pid else None
|
||||
if project is None:
|
||||
return
|
||||
if getattr(self, "_folder", None) is not None:
|
||||
self._folder.set_project_root(str(project.workspace_dir()))
|
||||
if getattr(self, "_structure", None) is not None:
|
||||
self._structure.set_workspace_project(pid)
|
||||
# Thư mục mới có thể vừa gỡ bỏ (hoặc tạo ra) một cảnh báo dùng chung.
|
||||
self._sync_folder_warning()
|
||||
|
||||
def _gan_nhan_canh_bao_thu_muc(self) -> None:
|
||||
"""Chèn nhãn cảnh báo ngay DƯỚI hàng chứa ô Thư mục làm việc.
|
||||
|
||||
|
||||
@@ -278,10 +278,17 @@ class AnthropicProvider(Provider):
|
||||
raise ProviderError(f"Anthropic: {evt.get('error', {}).get('message', 'error')}")
|
||||
resp.close()
|
||||
break # stream finished normally (or cancelled)
|
||||
except requests.RequestException as exc:
|
||||
except Exception as exc: # noqa: BLE001 — lọc lại ngay bên dưới
|
||||
resp.close()
|
||||
# Huỷ giữa chừng đóng socket, và urllib3 ném AttributeError
|
||||
# ("'NoneType' object has no attribute 'read'") chứ KHÔNG phải
|
||||
# RequestException — bắt hẹp là lỗi đó lọt ra ngoài và người dùng
|
||||
# thấy một lỗi Python đỏ thay vì "đã dừng". Chỉ nuốt khi thật sự
|
||||
# đang huỷ; lỗi khác vẫn ném tiếp nguyên vẹn.
|
||||
if self._is_cancelled(cancel):
|
||||
break
|
||||
if not isinstance(exc, requests.RequestException):
|
||||
raise
|
||||
if text_parts or blocks:
|
||||
if on_text:
|
||||
on_text("\n⚠ Kết nối bị ngắt giữa chừng — hiển thị phần đã nhận được.\n")
|
||||
|
||||
@@ -254,13 +254,19 @@ class OpenAICompatProvider(Provider):
|
||||
slot["args"] += fn["arguments"]
|
||||
resp.close()
|
||||
break # stream finished normally (or cancelled)
|
||||
except requests.RequestException as exc:
|
||||
except Exception as exc: # noqa: BLE001 — lọc lại ngay bên dưới
|
||||
resp.close()
|
||||
# If cancel was requested, close cleanly without retry
|
||||
# Huỷ giữa chừng đóng socket, và urllib3 ném AttributeError
|
||||
# ("'NoneType' object has no attribute 'read'") chứ KHÔNG phải
|
||||
# RequestException — bắt hẹp là lỗi đó lọt ra ngoài và người dùng
|
||||
# thấy một lỗi Python đỏ thay vì "đã dừng". Chỉ nuốt khi thật sự
|
||||
# đang huỷ; lỗi khác vẫn ném tiếp nguyên vẹn.
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
break
|
||||
if self._is_cancelled(cancel):
|
||||
break
|
||||
if not isinstance(exc, requests.RequestException):
|
||||
raise
|
||||
if text_parts or tool_acc:
|
||||
# Partial answer already on screen — keep it, note the cut.
|
||||
if on_text:
|
||||
|
||||
@@ -90,7 +90,7 @@ class AppContext:
|
||||
return load_project(pid)
|
||||
|
||||
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
|
||||
global default (``config.routing_mode_for``). This is what makes each
|
||||
workspace keep its own routing mode.
|
||||
@@ -101,15 +101,15 @@ class AppContext:
|
||||
project = self._current_project()
|
||||
if project is not None:
|
||||
mode = (project.routing_modes or {}).get(surface, "")
|
||||
if mode in self.config.ROUTING_MODES:
|
||||
return mode
|
||||
if mode in self.config.ROUTING_MODES: # old off/fallback → auto
|
||||
return self.config.user_routing_mode(mode)
|
||||
return self.config.routing_mode_for(surface)
|
||||
|
||||
def set_project_routing_mode(self, surface: str, mode: str) -> None:
|
||||
"""Persist a surface's routing mode for the ACTIVE workspace. With no
|
||||
workspace selected, falls back to the global setting so behaviour
|
||||
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()
|
||||
if project is None:
|
||||
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
|
||||
|
||||
|
||||
def test_fallback_mode_survives_a_round_trip_through_config(ctx) -> None:
|
||||
"""The new mode must be persistable, or the toggle could never select it."""
|
||||
ctx.config.set_routing_mode_for("cowork", "fallback")
|
||||
|
||||
assert ctx.config.routing_mode_for("cowork") == "fallback"
|
||||
assert ctx.project_routing_mode("cowork") == "fallback"
|
||||
def test_removed_modes_resolve_to_auto(ctx) -> None:
|
||||
"""Off/Fallback were removed from the UI: a stored value resolves to Auto."""
|
||||
for legacy in ("off", "fallback"):
|
||||
ctx.config.set_routing_mode_for("cowork", legacy)
|
||||
assert ctx.config.routing_mode_for("cowork") == "auto"
|
||||
assert ctx.project_routing_mode("cowork") == "auto"
|
||||
|
||||
|
||||
def test_unknown_persisted_mode_degrades_to_off(ctx) -> None:
|
||||
"""A hand-edited config must not enable routing by accident."""
|
||||
def test_unknown_persisted_mode_degrades_to_auto(ctx) -> None:
|
||||
"""A hand-edited config resolves to the default mode (Auto)."""
|
||||
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):
|
||||
a = _mk(ctx, "Alpha")
|
||||
ctx.active_project_id = a.project_id
|
||||
# Global default switch_mode is "off".
|
||||
assert ctx.project_routing_mode("cowork") == "off"
|
||||
# Global default switch_mode is "auto" (Off was removed).
|
||||
assert ctx.project_routing_mode("cowork") == "auto"
|
||||
# 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"
|
||||
|
||||
|
||||
def test_per_workspace_routing_is_isolated(ctx):
|
||||
a = _mk(ctx, "Alpha")
|
||||
b = _mk(ctx, "Beta")
|
||||
ctx.config.data["routing"]["switch_mode"] = "manual"
|
||||
|
||||
ctx.active_project_id = a.project_id
|
||||
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).
|
||||
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"
|
||||
|
||||
# A is unchanged.
|
||||
# B sets its own, independently.
|
||||
ctx.set_project_routing_mode("cowork", "auto")
|
||||
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"
|
||||
|
||||
# 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):
|
||||
a = _mk(ctx, "Alpha")
|
||||
@@ -68,7 +84,8 @@ def test_per_surface_isolated_within_a_workspace(ctx):
|
||||
# co4e untouched → global default.
|
||||
assert ctx.project_routing_mode("cowork") == "auto"
|
||||
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):
|
||||
|
||||
@@ -88,12 +88,21 @@ def test_best_for_returns_strong(service):
|
||||
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.ctx.config.data["routing"]["switch_mode"] = "off"
|
||||
r = service.route("cowork", "Write a Python function", "anthropic", "weak-model")
|
||||
assert r.mode == SwitchMode.OFF
|
||||
assert r.should_switch is False
|
||||
assert r.mode == SwitchMode.AUTO
|
||||
|
||||
|
||||
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):
|
||||
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"
|
||||
# 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_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.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._workspace_project = None
|
||||
v._workspace_dir = None # chốt còn theo cả đường dẫn, không chỉ id
|
||||
v.renderer = _Ghi()
|
||||
return v
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Đổi thư mục project thì đồ thị giữa màn GraphRAG phải quét lại.
|
||||
|
||||
Nối tiếp ``test_graphrag_follows_folder_change.py``. Sau khi đường dẫn trên
|
||||
thanh đã trỏ đúng thư mục mới, các node ở giữa màn vẫn là của thư mục cũ: không
|
||||
có lệnh quét lại nào được phát ra.
|
||||
|
||||
Nguyên nhân cùng một họ với hai mảnh trước — câu hỏi "có gì đổi không" được trả
|
||||
lời bằng project id chứ không bằng thứ thật sự quyết định kết quả quét:
|
||||
|
||||
project_changed = pid != self._active_project_id
|
||||
|
||||
Đổi thư mục làm việc ở màn Project giữ nguyên id, nên ``project_changed`` là
|
||||
False và cả khối phát tín hiệu lẫn khối gọi ``_scan()`` đều bị bỏ qua.
|
||||
|
||||
Cố ý KHÔNG dựng ``GraphRenderer`` thật: nó kéo theo QtWebEngine, dựng trong bộ
|
||||
``tests/ui`` làm cả bộ chết giữa chừng (xem docstring của
|
||||
``test_graphrag_follows_folder_change.py``). ``_on_project_changed`` là Python
|
||||
thuần trên các thuộc tính của chính nó, nên gọi thẳng với một ``self`` giả là đủ
|
||||
và đúng hơn — bài test chốt luồng quyết định, không chốt phần vẽ.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6", reason="cần PySide6 để nạp module renderer")
|
||||
|
||||
from cowork_local.core import projects as projects_mod
|
||||
from cowork_local.core.projects import Project
|
||||
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
|
||||
|
||||
|
||||
class _O:
|
||||
"""Vật thể ghi lại lời gọi, thay cho một widget Qt."""
|
||||
|
||||
def __init__(self, **thuoc_tinh):
|
||||
self.__dict__.update(thuoc_tinh)
|
||||
self.da_goi = []
|
||||
|
||||
def __getattr__(self, ten):
|
||||
def ghi(*args, **kwargs):
|
||||
self.da_goi.append((ten, args))
|
||||
return ghi
|
||||
|
||||
|
||||
class _ComboGia:
|
||||
def __init__(self, pid):
|
||||
self._pid = pid
|
||||
|
||||
def currentData(self):
|
||||
return self._pid
|
||||
|
||||
|
||||
class _OGia:
|
||||
"""Ô nhập đường dẫn: giữ được chữ, và ghi lại việc bị khoá."""
|
||||
|
||||
def __init__(self, text=""):
|
||||
self._text = text
|
||||
self.read_only = False
|
||||
|
||||
def text(self):
|
||||
return self._text
|
||||
|
||||
def setText(self, value):
|
||||
self._text = value
|
||||
|
||||
def setReadOnly(self, value):
|
||||
self.read_only = value
|
||||
|
||||
|
||||
class _Renderer:
|
||||
"""``self`` giả cho ``GraphRenderer._on_project_changed``."""
|
||||
|
||||
def __init__(self, pid, active_id="", active_path="", hien=True):
|
||||
self.project_combo = _ComboGia(pid)
|
||||
self.path_edit = _OGia()
|
||||
self._pick_btn = _O()
|
||||
self.project_changed = _O()
|
||||
self._active_project_id = active_id
|
||||
self._active_path = active_path
|
||||
self._needs_scan = False
|
||||
self._hien = hien
|
||||
self.lan_quet = 0
|
||||
|
||||
def isVisible(self):
|
||||
return self._hien
|
||||
|
||||
def _scan(self):
|
||||
self.lan_quet += 1
|
||||
|
||||
# -- tiện cho khẳng định --------------------------------------------- #
|
||||
@property
|
||||
def so_lan_bao_doi(self):
|
||||
"""Số lần phát tín hiệu "đã đổi mục tiêu"."""
|
||||
return sum(1 for ten, _ in self.project_changed.da_goi if ten == "emit")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def du_an(monkeypatch, tmp_path):
|
||||
"""Một project duy nhất trong kho giả, trỏ vào ``tmp_path/cu``."""
|
||||
p = Project(project_id="p1", name="test", output_dir=str(tmp_path / "cu"))
|
||||
monkeypatch.setattr(projects_mod, "load_project",
|
||||
lambda pid, directory=None: p if pid == "p1" else None)
|
||||
return p
|
||||
|
||||
|
||||
def _chay(renderer):
|
||||
"""Gọi đúng hàm thật với ``self`` giả."""
|
||||
GraphRenderer._on_project_changed(renderer, 0)
|
||||
|
||||
|
||||
def test_cung_project_thu_muc_moi_thi_quet_lai(du_an, tmp_path):
|
||||
"""Đây là chỗ hỏng người dùng báo: đường dẫn đổi mà node giữa màn thì không."""
|
||||
r = _Renderer("p1")
|
||||
_chay(r) # lần đầu: khoá vào project
|
||||
assert r.lan_quet == 1
|
||||
|
||||
du_an.output_dir = str(tmp_path / "moi") # người dùng bấm "Đổi" ở tab Project
|
||||
_chay(r)
|
||||
|
||||
assert r.path_edit.text() == str(tmp_path / "moi")
|
||||
assert r.lan_quet == 2, "đổi thư mục xong nhưng không quét lại — node vẫn của thư mục cũ"
|
||||
assert r.so_lan_bao_doi == 2, (
|
||||
"phải báo đổi để khung hỏi-đáp bỏ phần trích xuất của thư mục cũ")
|
||||
|
||||
|
||||
def test_cung_project_cung_thu_muc_thi_khong_quet_lai(du_an):
|
||||
"""Bảo vệ sẵn có: ``_bind_project`` chạy lại mỗi lần vào lại màn Workspace,
|
||||
quét lại vô cớ là vừa giật vừa tốn."""
|
||||
r = _Renderer("p1")
|
||||
_chay(r)
|
||||
assert r.lan_quet == 1
|
||||
|
||||
_chay(r)
|
||||
_chay(r)
|
||||
|
||||
assert r.lan_quet == 1
|
||||
|
||||
|
||||
def test_doi_sang_project_khac_van_quet_lai(du_an, tmp_path, monkeypatch):
|
||||
"""Hành vi vốn có, không được mất."""
|
||||
khac = Project(project_id="p2", name="khac", output_dir=str(tmp_path / "cua-p2"))
|
||||
monkeypatch.setattr(projects_mod, "load_project",
|
||||
lambda pid, directory=None: du_an if pid == "p1" else khac)
|
||||
r = _Renderer("p1")
|
||||
_chay(r)
|
||||
|
||||
r.project_combo._pid = "p2"
|
||||
_chay(r)
|
||||
|
||||
assert r.lan_quet == 2
|
||||
assert r.path_edit.text() == str(tmp_path / "cua-p2")
|
||||
|
||||
|
||||
def test_man_dang_an_thi_hoan_quet_chu_khong_quet_ngay(du_an, tmp_path):
|
||||
"""Đổi thư mục từ màn Project trong khi GraphRAG đang ẩn: đánh dấu để quét
|
||||
ở lần vào sau, đúng luật hoãn mà ``auto_scan_and_fit`` dựa vào."""
|
||||
r = _Renderer("p1", hien=False)
|
||||
_chay(r)
|
||||
# Lần khoá đầu tiên đã đặt cờ rồi; xoá đi để bài này thật sự kiểm được
|
||||
# lần ĐỔI THƯ MỤC, chứ không xanh nhờ cờ còn sót của lần trước.
|
||||
r._needs_scan = False
|
||||
|
||||
du_an.output_dir = str(tmp_path / "moi")
|
||||
_chay(r)
|
||||
|
||||
assert r.lan_quet == 0
|
||||
assert r._needs_scan is True
|
||||
@@ -0,0 +1,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)
|
||||
if lbl is None:
|
||||
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:
|
||||
"""Á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.
|
||||
"""
|
||||
|
||||
mode_changed = Signal(str) # "off" | "auto" | "manual" | "fallback"
|
||||
mode_changed = Signal(str) # "auto" | "manual"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -76,14 +76,11 @@ class RoutingToggle(QWidget):
|
||||
# it the width stays frozen at the language the widget was built in and
|
||||
# the longer translation is cut off.
|
||||
self._combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
||||
# (data value, i18n key) — data is the persisted mode string. Order is
|
||||
# least-to-most autonomous, with Fallback (R03-T03) last because it is
|
||||
# the "only when something breaks" mode rather than a stronger Auto.
|
||||
# (data value, i18n key) — data is the persisted mode string. Off and
|
||||
# Fallback were dropped: only Auto and Manual remain selectable.
|
||||
self._modes = [
|
||||
("off", "routing.mode_off"),
|
||||
("auto", "routing.mode_auto"),
|
||||
("manual", "routing.mode_manual"),
|
||||
("fallback", "routing.mode_fallback"),
|
||||
]
|
||||
for value, key in self._modes:
|
||||
self._combo.addItem(tr(key), value)
|
||||
@@ -100,16 +97,16 @@ class RoutingToggle(QWidget):
|
||||
on_language_changed(self.retranslate)
|
||||
|
||||
def current_mode(self) -> str:
|
||||
"""Chế độ định tuyến đang chọn; 'off' nếu chưa đặt."""
|
||||
return self._combo.currentData() or "off"
|
||||
"""Chế độ định tuyến đang chọn; 'auto' nếu chưa đặt."""
|
||||
return self._combo.currentData() or "auto"
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Re-read the backing mode (e.g. after switching workspace) and show it
|
||||
without emitting a spurious change."""
|
||||
try:
|
||||
mode = self._get_mode() or "off"
|
||||
mode = self._get_mode() or "auto"
|
||||
except Exception: # noqa: BLE001
|
||||
mode = "off"
|
||||
mode = "auto"
|
||||
idx = self._combo.findData(mode)
|
||||
if idx < 0:
|
||||
idx = 0
|
||||
|
||||
+7
-1
@@ -14,6 +14,7 @@ from ..core.history import (
|
||||
rename_conversation, set_pinned,
|
||||
)
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..presentation.chat.chat_helpers import clip_chars
|
||||
from .dialog_buttons import ask_text, confirm
|
||||
from .icons import collapse_left_icon, dot_icon, DOT_BLUE, icon
|
||||
from .widgets import CollapseStrip
|
||||
@@ -81,6 +82,7 @@ class HistorySidebar(QWidget):
|
||||
expand_requested = Signal() # strip clicked: re-expand
|
||||
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
|
||||
conversation_renamed = Signal(str, str) # session_id, new title — the open chat retitles itself
|
||||
|
||||
def __init__(self, ctx: AppContext):
|
||||
"""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_running = sid in self.running_ids
|
||||
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.
|
||||
if is_running:
|
||||
item.setIcon(0, dot_icon(DOT_BLUE))
|
||||
@@ -352,6 +356,8 @@ class HistorySidebar(QWidget):
|
||||
if ok and new.strip():
|
||||
rename_conversation(path, new.strip())
|
||||
self.refresh()
|
||||
sid = load_conversation(path).get("session_id", "")
|
||||
self.conversation_renamed.emit(sid, new.strip())
|
||||
elif chosen == del_act:
|
||||
if confirm(self, tr("sidebar.delete.title"),
|
||||
tr("sidebar.delete.confirm", title=title)):
|
||||
|
||||
+3
-2
@@ -490,8 +490,9 @@ def section_panels(sections, width: int = 260):
|
||||
index.currentRowChanged.connect(stack.setCurrentIndex)
|
||||
index.setCurrentRow(0)
|
||||
|
||||
natural = max(index.fontMetrics().horizontalAdvance(lab) for lab, _w in sections) + 36
|
||||
index.setFixedWidth(max(120, min(width, natural)))
|
||||
# Sized for the SELECTED look (bold + padding), or the picked label is cut.
|
||||
from ..presentation.shared.list_sizing import selected_label_width
|
||||
index.setFixedWidth(max(120, min(width, selected_label_width(index, [lab for lab, _w in sections]))))
|
||||
return index, stack
|
||||
|
||||
|
||||
|
||||
@@ -339,6 +339,8 @@ class WorkspaceTab(ProjectEditingMixin, ProjectFolderRuleMixin, QWidget):
|
||||
sb.expand_requested.connect(lambda: self._on_history_fold(False))
|
||||
sb.refresh_requested.connect(self._on_sidebar_refresh)
|
||||
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:
|
||||
"""Its chevron closes the panel away, back to the drawn layout."""
|
||||
@@ -947,6 +949,7 @@ class WorkspaceTab(ProjectEditingMixin, ProjectFolderRuleMixin, QWidget):
|
||||
project.output_dir = chosen
|
||||
save_project(project)
|
||||
self.folder_lbl.setText(chosen)
|
||||
self.rebind_workspace_folder()
|
||||
self.status_message.emit(tr("workspace.saved", name=project.name))
|
||||
|
||||
def _open_workspace(self) -> None:
|
||||
@@ -1002,6 +1005,7 @@ class WorkspaceTab(ProjectEditingMixin, ProjectFolderRuleMixin, QWidget):
|
||||
project.cloud_source = cloud_source
|
||||
save_project(project)
|
||||
self.folder_lbl.setText(str(local_dir))
|
||||
self.rebind_workspace_folder()
|
||||
self._refresh_cloud_badge(project)
|
||||
self.projects_changed.emit()
|
||||
if report.errors:
|
||||
|
||||
Reference in New Issue
Block a user