Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6b68a0bf8 | ||
|
|
0b6b220bd9 | ||
|
|
fefc9f94db |
@@ -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": "",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,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
|
||||
@@ -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,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"]
|
||||
+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)):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user