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 = QApplication.instance() or QApplication(argv)
|
||||||
app.setApplicationName(APP_NAME)
|
app.setApplicationName(APP_NAME)
|
||||||
app.setWindowIcon(app_icon())
|
app.setWindowIcon(app_icon())
|
||||||
|
try:
|
||||||
|
from .config import CONFIG_DIR
|
||||||
|
from .presentation.shell import crash_guard
|
||||||
|
crash_guard.install(Path(CONFIG_DIR) / "logs")
|
||||||
|
except Exception: # noqa: BLE001 - crash logging must never block startup
|
||||||
|
crash_guard = None
|
||||||
# Composition Root: presentation/shell/bootstrap.py quyết định app chạy
|
# Composition Root: presentation/shell/bootstrap.py quyết định app chạy
|
||||||
# bằng mảnh nào. Từ R02, đó là JsonConfigRepository + kho bí mật của hệ
|
# bằng mảnh nào. Từ R02, đó là JsonConfigRepository + kho bí mật của hệ
|
||||||
# điều hành, không còn config.py::AppConfig.
|
# điều hành, không còn config.py::AppConfig.
|
||||||
@@ -132,4 +138,10 @@ def run(argv: List[str] | None = None) -> int:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
win.show()
|
win.show()
|
||||||
|
if crash_guard is not None:
|
||||||
|
try:
|
||||||
|
watchdog = crash_guard.HangWatchdog(DISPLAY_NAME, win)
|
||||||
|
app.aboutToQuit.connect(watchdog.stop)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
return app.exec()
|
return app.exec()
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
|
|||||||
# file (~/.cowork_local/assessments.json + assessments_history/), not here —
|
# file (~/.cowork_local/assessments.json + assessments_history/), not here —
|
||||||
# this section is only the behaviour config the user edits.
|
# this section is only the behaviour config the user edits.
|
||||||
"routing": {
|
"routing": {
|
||||||
"switch_mode": "off", # global default: "off" | "auto" | "manual"
|
"switch_mode": "auto", # global default: "auto" | "manual"
|
||||||
"policy": "balanced", # "quality" | "cost" | "latency" | "balanced"
|
"policy": "balanced", # "quality" | "cost" | "latency" | "balanced"
|
||||||
"min_score_gain": 0.05, # only switch if the new model beats current by ≥ this
|
"min_score_gain": 0.05, # only switch if the new model beats current by ≥ this
|
||||||
"confirm_timeout_sec": 60, # (manual) auto-keep current if the user doesn't confirm in time
|
"confirm_timeout_sec": 60, # (manual) auto-keep current if the user doesn't confirm in time
|
||||||
@@ -246,7 +246,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
|
|||||||
"judge_model": "", # fixed cheap judge model ("" → a per-provider default)
|
"judge_model": "", # fixed cheap judge model ("" → a per-provider default)
|
||||||
"candidates": [], # explicit [{provider, model_id, tier}]; empty → discover from providers
|
"candidates": [], # explicit [{provider, model_id, tier}]; empty → discover from providers
|
||||||
"auto_reassess_on_add": True, # reassess a newly-added model as soon as it's added
|
"auto_reassess_on_add": True, # reassess a newly-added model as soon as it's added
|
||||||
# Per-surface Off/Auto/Manual toggle state (the chat-screen toggle). An
|
# Per-surface Auto/Manual toggle state (the chat-screen toggle). An
|
||||||
# empty string means "follow the global switch_mode above".
|
# empty string means "follow the global switch_mode above".
|
||||||
"surface_modes": {
|
"surface_modes": {
|
||||||
"cowork": "",
|
"cowork": "",
|
||||||
|
|||||||
@@ -75,26 +75,16 @@ class RoutingScheduler(QObject):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _routing_enabled_anywhere(self) -> bool:
|
def _routing_enabled_anywhere(self) -> bool:
|
||||||
"""Is routing actually in use? True if the global mode is auto/manual OR
|
"""Is routing actually in use? Always, now: the Off mode was removed and
|
||||||
any chat surface overrides to auto/manual. When everything is Off, the
|
every stored value resolves to Auto or Manual (see
|
||||||
assessment scores would never be consulted — so we don't spend tokens
|
``config.user_routing_mode``). Paid probing is switched off through
|
||||||
probing for them (no surprise cost on a fresh install)."""
|
``reassess_interval_hours = 0`` instead."""
|
||||||
try:
|
|
||||||
routing = self.ctx.config.routing
|
|
||||||
if (routing.get("switch_mode") or "off") in ("auto", "manual"):
|
|
||||||
return True
|
return True
|
||||||
for m in (routing.get("surface_modes") or {}).values():
|
|
||||||
if m in ("auto", "manual"):
|
|
||||||
return True
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
pass
|
|
||||||
return False
|
|
||||||
|
|
||||||
def is_due(self) -> bool:
|
def is_due(self) -> bool:
|
||||||
"""Đã đến lúc chấm điểm lại chưa.
|
"""Đã đến lúc chấm điểm lại chưa.
|
||||||
|
|
||||||
Tắt định tuyến ở mọi bề mặt thì KHÔNG dò — dò model là lượt gọi có tính phí,
|
Dò model là lượt gọi có tính phí: đặt chu kỳ chấm lại = 0 thì không dò.
|
||||||
không được tiêu tiền cho một tính năng người dùng đã tắt.
|
|
||||||
"""
|
"""
|
||||||
if not self._routing_enabled_anywhere():
|
if not self._routing_enabled_anywhere():
|
||||||
return False # routing off everywhere → don't probe (would be wasted cost)
|
return False # routing off everywhere → don't probe (would be wasted cost)
|
||||||
|
|||||||
@@ -155,6 +155,12 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
|||||||
"app.lang.switching": {
|
"app.lang.switching": {
|
||||||
"en": "Switching language…", "ja": "言語を切り替えています…",
|
"en": "Switching language…", "ja": "言語を切り替えています…",
|
||||||
"vi": "Đang đổi ngôn ngữ…"},
|
"vi": "Đang đổi ngôn ngữ…"},
|
||||||
|
# Popup do luồng canh treo mở khi giao diện đứng quá vài giây
|
||||||
|
# (presentation/shell/crash_guard.py).
|
||||||
|
"app.hang.message": {
|
||||||
|
"en": "Processing, please wait…\n\nThis window closes by itself once the app responds again.",
|
||||||
|
"ja": "処理中です。しばらくお待ちください…\n\nアプリが応答を再開すると、このウィンドウは自動的に閉じます。",
|
||||||
|
"vi": "Đang xử lý, vui lòng đợi…\n\nCửa sổ này tự đóng khi app phản hồi lại."},
|
||||||
"app.tab.dashboard": {"en": "Dashboard", "ja": "ダッシュボード", "vi": "Dashboard"},
|
"app.tab.dashboard": {"en": "Dashboard", "ja": "ダッシュボード", "vi": "Dashboard"},
|
||||||
"app.tab.schedule": {"en": "Schedule Task", "ja": "タスクスケジュール", "vi": "Schedule Task"},
|
"app.tab.schedule": {"en": "Schedule Task", "ja": "タスクスケジュール", "vi": "Schedule Task"},
|
||||||
"app.tab.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
|
"app.tab.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
|
||||||
|
|||||||
@@ -258,6 +258,14 @@ class JsonConfigRepository(ConfigSectionsMixin):
|
|||||||
#: là bản chính; ``tests/test_config_repository.py`` có bài đối chiếu với
|
#: là bản chính; ``tests/test_config_repository.py`` có bài đối chiếu với
|
||||||
#: ``config.py`` để hai bên lệch nhau là đỏ ngay.
|
#: ``config.py`` để hai bên lệch nhau là đỏ ngay.
|
||||||
ROUTING_MODES = ("off", "auto", "manual", "fallback")
|
ROUTING_MODES = ("off", "auto", "manual", "fallback")
|
||||||
|
#: Chế độ người dùng còn chọn được. "off"/"fallback" đã bỏ khỏi giao diện;
|
||||||
|
#: giá trị cũ còn lưu trong config/project (hoặc giá trị lạ) đều hiểu là "auto".
|
||||||
|
USER_ROUTING_MODES = ("auto", "manual")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def user_routing_mode(cls, mode: str) -> str:
|
||||||
|
"""Quy một giá trị đã lưu về chế độ người dùng chọn được (mặc định "auto")."""
|
||||||
|
return mode if mode in cls.USER_ROUTING_MODES else "auto"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls, path: Path | None = None, *, secrets: SecretStore | None = None):
|
def load(cls, path: Path | None = None, *, secrets: SecretStore | None = None):
|
||||||
@@ -313,16 +321,14 @@ class JsonConfigRepository(ConfigSectionsMixin):
|
|||||||
"""Chế độ có hiệu lực cho một bề mặt chat.
|
"""Chế độ có hiệu lực cho một bề mặt chat.
|
||||||
|
|
||||||
Đặt riêng cho bề mặt thì thắng; để trống thì lấy ``switch_mode`` chung.
|
Đặt riêng cho bề mặt thì thắng; để trống thì lấy ``switch_mode`` chung.
|
||||||
Giá trị lạ rơi về "off" — định tuyến luôn là thứ phải bật, kể cả khi
|
Chỉ còn Auto/Manual: "off", "fallback" cũ hay giá trị lạ đều hiểu là "auto"."""
|
||||||
có người sửa tay file cấu hình."""
|
|
||||||
routing = self.routing
|
routing = self.routing
|
||||||
override = (routing.get("surface_modes", {}) or {}).get(surface, "")
|
override = (routing.get("surface_modes", {}) or {}).get(surface, "")
|
||||||
mode = override or routing.get("switch_mode", "off")
|
return self.user_routing_mode(override or routing.get("switch_mode", ""))
|
||||||
return mode if mode in self.ROUTING_MODES else "off"
|
|
||||||
|
|
||||||
def set_routing_mode_for(self, surface: str, mode: str) -> None:
|
def set_routing_mode_for(self, surface: str, mode: str) -> None:
|
||||||
"""Đặt chế độ định tuyến riêng cho một bề mặt chat, ghi đĩa ngay."""
|
"""Đặt chế độ định tuyến riêng cho một bề mặt chat, ghi đĩa ngay."""
|
||||||
mode = mode if mode in self.ROUTING_MODES else "off"
|
mode = self.user_routing_mode(mode)
|
||||||
self.routing.setdefault("surface_modes", {})[surface] = mode
|
self.routing.setdefault("surface_modes", {})[surface] = mode
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ from typing import Any, Dict, List, Optional
|
|||||||
|
|
||||||
_PLAN_ICONS = {"pending": "○", "running": "▶", "done": "✓", "error": "✗"}
|
_PLAN_ICONS = {"pending": "○", "running": "▶", "done": "✓", "error": "✗"}
|
||||||
|
|
||||||
|
HEADER_TITLE_MAX_CHARS = 10 # thanh tiêu đề khung chat chỉ hiện tối đa ngần này ký tự
|
||||||
|
|
||||||
|
|
||||||
|
def clip_chars(text: str, limit: int = HEADER_TITLE_MAX_CHARS) -> str:
|
||||||
|
"""Giữ tối đa ``limit`` ký tự, dư thì cắt và thêm "…"."""
|
||||||
|
return text if len(text) <= limit else text[:limit].rstrip() + "…"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _format_plan_steps(steps) -> str:
|
def _format_plan_steps(steps) -> str:
|
||||||
|
|||||||
@@ -224,6 +224,23 @@ class ChatSessionMixin:
|
|||||||
if getattr(self, "_usage_total_lbl", None) is not None:
|
if getattr(self, "_usage_total_lbl", None) is not None:
|
||||||
self.refresh_usage()
|
self.refresh_usage()
|
||||||
|
|
||||||
|
def set_title_label(self, lbl, fallback: str) -> None:
|
||||||
|
"""Đặt tiêu đề hội thoại lên nhãn: tối đa 10 ký tự, bản đầy đủ ở tooltip."""
|
||||||
|
from .chat_helpers import clip_chars
|
||||||
|
|
||||||
|
full = self.title or fallback
|
||||||
|
lbl.setText(clip_chars(full))
|
||||||
|
lbl.setToolTip(full)
|
||||||
|
|
||||||
|
def apply_renamed_title(self, session_id: str, title: str) -> None:
|
||||||
|
"""Hội thoại đang mở vừa được đổi tên ở cột lịch sử: đổi luôn ``self.title``.
|
||||||
|
|
||||||
|
Không chỉ để thanh tiêu đề cập nhật ngay — lượt chat kế tiếp lưu bằng
|
||||||
|
``self.title``, giữ tên cũ sẽ ghi đè mất tên người dùng vừa đặt."""
|
||||||
|
if session_id and session_id == self.session_id:
|
||||||
|
self.title = title
|
||||||
|
self._notify_title()
|
||||||
|
|
||||||
def load_conversation(self, conv: Dict[str, Any]) -> None:
|
def load_conversation(self, conv: Dict[str, Any]) -> None:
|
||||||
"""Switch the view to a stored conversation. Allowed while work is running —
|
"""Switch the view to a stored conversation. Allowed while work is running —
|
||||||
the current turns keep going in the background."""
|
the current turns keep going in the background."""
|
||||||
|
|||||||
@@ -12,11 +12,9 @@ from PySide6.QtWidgets import (
|
|||||||
|
|
||||||
from ...i18n import tr
|
from ...i18n import tr
|
||||||
|
|
||||||
#: Các chế độ định tuyến. Danh sách này phải khớp ``config.py::AppConfig
|
#: Các chế độ người dùng chọn được — khớp ``AppConfig.USER_ROUTING_MODES``.
|
||||||
#: .ROUTING_MODES`` — Delta thêm "fallback" ở R03-T03 và nếu quên đồng bộ
|
#: Off/Fallback đã bỏ; giá trị cũ còn lưu được hiểu là Auto.
|
||||||
#: chỗ này thì người dùng không chọn được chế độ đó, mà không có lỗi nào báo.
|
MODE_KEYS = (("auto", "routing.mode_auto"), ("manual", "routing.mode_manual"))
|
||||||
MODE_KEYS = (("off", "routing.mode_off"), ("auto", "routing.mode_auto"),
|
|
||||||
("manual", "routing.mode_manual"))
|
|
||||||
|
|
||||||
POLICY_KEYS = (("quality", "routing.policy_quality"), ("cost", "routing.policy_cost"),
|
POLICY_KEYS = (("quality", "routing.policy_quality"), ("cost", "routing.policy_cost"),
|
||||||
("latency", "routing.policy_latency"),
|
("latency", "routing.policy_latency"),
|
||||||
@@ -37,7 +35,7 @@ class RoutingSettingsWidget(QGroupBox):
|
|||||||
self.mode = QComboBox()
|
self.mode = QComboBox()
|
||||||
for value, key in MODE_KEYS:
|
for value, key in MODE_KEYS:
|
||||||
self.mode.addItem(tr(key), value)
|
self.mode.addItem(tr(key), value)
|
||||||
_select(self.mode, routing.get("switch_mode", "off"))
|
_select(self.mode, routing.get("switch_mode", "auto")) # off/fallback cũ → mục đầu (Auto)
|
||||||
form.addRow(tr("routing.settings_mode"), self.mode)
|
form.addRow(tr("routing.settings_mode"), self.mode)
|
||||||
|
|
||||||
self.policy = QComboBox()
|
self.policy = QComboBox()
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Ghi lại mọi lần app chết hoặc treo, và báo "Đang xử lý…" khi giao diện đứng.
|
||||||
|
|
||||||
|
Trước đây app văng ra (mã 0xC0000409) mà không để lại dấu vết gì: lỗi ở tầng
|
||||||
|
native không in traceback Python, còn cửa sổ console thì đóng ngay theo app. Mô-đun
|
||||||
|
này ghi mọi thứ vào ``~/.cowork_local/logs/``:
|
||||||
|
|
||||||
|
* ``crash.log``: stack của mọi luồng lúc tiến trình chết (``faulthandler``), kèm
|
||||||
|
exception Python không ai bắt, ở luồng chính lẫn luồng nền. Exception Python
|
||||||
|
chỉ được ghi lại, app vẫn chạy tiếp.
|
||||||
|
* ``qt.log``: cảnh báo, lỗi và lỗi nghiêm trọng của Qt (cũng vẫn in ra console).
|
||||||
|
* ``hang.log``: stack của mọi luồng mỗi khi luồng giao diện đứng quá
|
||||||
|
``HANG_SECONDS`` giây, để biết app đang kẹt ở dòng nào.
|
||||||
|
|
||||||
|
Luồng giao diện đang đứng thì không thể tự vẽ popup, nên popup "Đang xử lý…" do một
|
||||||
|
luồng canh riêng mở bằng hộp thoại gốc của Windows. Giao diện chạy lại thì hộp
|
||||||
|
thoại tự đóng.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import faulthandler
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from PySide6.QtCore import QTimer, QtMsgType, qInstallMessageHandler
|
||||||
|
|
||||||
|
HANG_SECONDS = 5.0 # luồng giao diện đứng quá ngần này giây thì coi là treo
|
||||||
|
_BEAT_MS = 500 # nhịp luồng giao diện báo "vẫn sống"
|
||||||
|
|
||||||
|
_crash_file = None # giữ file mở suốt đời app: faulthandler ghi vào lúc chết
|
||||||
|
_log_dir: Optional[Path] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _stamp() -> str:
|
||||||
|
"""Mốc giờ hiện tại, dùng làm tiêu đề mỗi mục trong log."""
|
||||||
|
return datetime.datetime.now().isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def _append(name: str, text: str) -> None:
|
||||||
|
"""Ghi thêm vào một file log. Ghi log hỏng thì bỏ qua, không kéo app theo."""
|
||||||
|
if _log_dir is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(_log_dir / name, "a", encoding="utf-8") as f:
|
||||||
|
f.write(text)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _log_exception(where: str, exc_type, exc, tb) -> None:
|
||||||
|
"""Ghi một exception không ai bắt vào crash.log và in ra console."""
|
||||||
|
body = "".join(traceback.format_exception(exc_type, exc, tb))
|
||||||
|
_append("crash.log", f"\n=== {_stamp()} exception chưa bắt ({where}) ===\n{body}")
|
||||||
|
if sys.__stderr__:
|
||||||
|
sys.__stderr__.write(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _qt_message(mode, context, message) -> None:
|
||||||
|
"""Chuyển thông báo của Qt vào qt.log, vẫn in ra console như trước."""
|
||||||
|
level = {QtMsgType.QtWarningMsg: "WARNING", QtMsgType.QtCriticalMsg: "CRITICAL",
|
||||||
|
QtMsgType.QtFatalMsg: "FATAL"}.get(mode)
|
||||||
|
if sys.__stderr__:
|
||||||
|
sys.__stderr__.write(message + "\n")
|
||||||
|
if level is None:
|
||||||
|
return # debug/info: chỉ in, không ghi file
|
||||||
|
_append("qt.log", f"{_stamp()} {level}: {message}\n")
|
||||||
|
if mode == QtMsgType.QtFatalMsg and _crash_file is not None:
|
||||||
|
faulthandler.dump_traceback(_crash_file, all_threads=True)
|
||||||
|
|
||||||
|
|
||||||
|
def install(log_dir: Path) -> None:
|
||||||
|
"""Bật ghi log crash. Gọi một lần, sớm nhất có thể sau khi có QApplication."""
|
||||||
|
global _crash_file, _log_dir
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
_log_dir = log_dir
|
||||||
|
_crash_file = open(log_dir / "crash.log", "a", encoding="utf-8") # noqa: SIM115
|
||||||
|
_crash_file.write(f"\n=== {_stamp()} app khởi động ===\n")
|
||||||
|
_crash_file.flush()
|
||||||
|
faulthandler.enable(file=_crash_file, all_threads=True)
|
||||||
|
sys.excepthook = lambda t, e, tb: _log_exception("luồng chính", t, e, tb)
|
||||||
|
threading.excepthook = lambda a: _log_exception(
|
||||||
|
f"luồng {a.thread.name if a.thread else '?'}", a.exc_type, a.exc_value, a.exc_traceback)
|
||||||
|
qInstallMessageHandler(_qt_message)
|
||||||
|
|
||||||
|
|
||||||
|
class HangWatchdog:
|
||||||
|
"""Canh luồng giao diện: đứng quá ``HANG_SECONDS`` giây thì ghi stack vào
|
||||||
|
hang.log và mở popup "Đang xử lý…", chạy lại thì đóng popup.
|
||||||
|
|
||||||
|
Luồng giao diện đều đặn cập nhật nhịp qua một ``QTimer``; một luồng nền kiểm tra
|
||||||
|
nhịp đó. Luồng nền không đụng vào widget Qt nào.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, title: str, parent=None) -> None:
|
||||||
|
self._title = title
|
||||||
|
self._last_beat = time.monotonic()
|
||||||
|
self._timer = QTimer(parent)
|
||||||
|
self._timer.timeout.connect(self._beat)
|
||||||
|
self._timer.start(_BEAT_MS)
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._popup: Optional[threading.Thread] = None
|
||||||
|
self._popup_title = title
|
||||||
|
threading.Thread(target=self._watch, name="hang-watchdog", daemon=True).start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Dừng canh (khi app thoát)."""
|
||||||
|
self._stop.set()
|
||||||
|
self._timer.stop()
|
||||||
|
|
||||||
|
def _beat(self) -> None:
|
||||||
|
self._last_beat = time.monotonic()
|
||||||
|
|
||||||
|
def _watch(self) -> None:
|
||||||
|
stalled = False
|
||||||
|
while not self._stop.wait(0.5):
|
||||||
|
lag = time.monotonic() - self._last_beat
|
||||||
|
if lag >= HANG_SECONDS and not stalled:
|
||||||
|
stalled = True
|
||||||
|
self._dump_stacks(lag)
|
||||||
|
self._show_popup()
|
||||||
|
elif lag < HANG_SECONDS and stalled:
|
||||||
|
stalled = False
|
||||||
|
self._close_popup()
|
||||||
|
|
||||||
|
def _dump_stacks(self, lag: float) -> None:
|
||||||
|
if _log_dir is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(_log_dir / "hang.log", "a", encoding="utf-8") as f:
|
||||||
|
f.write(f"\n=== {_stamp()} giao diện đứng {lag:.1f}s ===\n")
|
||||||
|
f.flush()
|
||||||
|
faulthandler.dump_traceback(f, all_threads=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _show_popup(self) -> None:
|
||||||
|
if sys.platform != "win32":
|
||||||
|
return
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
from ...i18n import tr
|
||||||
|
|
||||||
|
# Đọc chữ NGAY LÚC hiện popup: luôn khớp ngôn ngữ đang chọn trong app,
|
||||||
|
# kể cả khi người dùng vừa đổi ngôn ngữ.
|
||||||
|
title, message = self._title, tr("app.hang.message")
|
||||||
|
# MB_ICONINFORMATION | MB_TOPMOST | MB_SETFOREGROUND. MessageBoxW có vòng
|
||||||
|
# thông điệp riêng trên luồng này, nên hiện được dù luồng giao diện đang đứng.
|
||||||
|
flags = 0x40 | 0x40000 | 0x10000
|
||||||
|
self._popup_title = title
|
||||||
|
self._popup = threading.Thread(
|
||||||
|
target=lambda: ctypes.windll.user32.MessageBoxW(None, message, title, flags),
|
||||||
|
name="hang-popup", daemon=True)
|
||||||
|
self._popup.start()
|
||||||
|
|
||||||
|
def _close_popup(self) -> None:
|
||||||
|
if self._popup is None or sys.platform != "win32":
|
||||||
|
return
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
for _ in range(10): # hộp thoại có thể chưa kịp hiện ra
|
||||||
|
hwnd = user32.FindWindowW(None, self._popup_title)
|
||||||
|
if hwnd:
|
||||||
|
user32.PostMessageW(hwnd, 0x0010, 0, 0) # WM_CLOSE
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
self._popup = None
|
||||||
@@ -90,7 +90,7 @@ class AppContext:
|
|||||||
return load_project(pid)
|
return load_project(pid)
|
||||||
|
|
||||||
def project_routing_mode(self, surface: str) -> str:
|
def project_routing_mode(self, surface: str) -> str:
|
||||||
"""Effective Off/Auto/Manual/Fallback routing mode for a chat ``surface``
|
"""Effective Auto/Manual routing mode for a chat ``surface``
|
||||||
in the ACTIVE workspace: the workspace's own override wins; otherwise the
|
in the ACTIVE workspace: the workspace's own override wins; otherwise the
|
||||||
global default (``config.routing_mode_for``). This is what makes each
|
global default (``config.routing_mode_for``). This is what makes each
|
||||||
workspace keep its own routing mode.
|
workspace keep its own routing mode.
|
||||||
@@ -101,15 +101,15 @@ class AppContext:
|
|||||||
project = self._current_project()
|
project = self._current_project()
|
||||||
if project is not None:
|
if project is not None:
|
||||||
mode = (project.routing_modes or {}).get(surface, "")
|
mode = (project.routing_modes or {}).get(surface, "")
|
||||||
if mode in self.config.ROUTING_MODES:
|
if mode in self.config.ROUTING_MODES: # old off/fallback → auto
|
||||||
return mode
|
return self.config.user_routing_mode(mode)
|
||||||
return self.config.routing_mode_for(surface)
|
return self.config.routing_mode_for(surface)
|
||||||
|
|
||||||
def set_project_routing_mode(self, surface: str, mode: str) -> None:
|
def set_project_routing_mode(self, surface: str, mode: str) -> None:
|
||||||
"""Persist a surface's routing mode for the ACTIVE workspace. With no
|
"""Persist a surface's routing mode for the ACTIVE workspace. With no
|
||||||
workspace selected, falls back to the global setting so behaviour
|
workspace selected, falls back to the global setting so behaviour
|
||||||
outside a project stays global."""
|
outside a project stays global."""
|
||||||
mode = mode if mode in self.config.ROUTING_MODES else "off"
|
mode = self.config.user_routing_mode(mode)
|
||||||
project = self._current_project()
|
project = self._current_project()
|
||||||
if project is None:
|
if project is None:
|
||||||
self.config.set_routing_mode_for(surface, mode)
|
self.config.set_routing_mode_for(surface, mode)
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Đổi tên hội thoại ở cột lịch sử thì thanh tiêu đề Cowork đổi theo ngay.
|
||||||
|
|
||||||
|
Trước đây nhãn tiêu đề giữ tên cũ cho tới khi người dùng mở lại hội thoại, và
|
||||||
|
``self.title`` cũ còn ghi đè tên mới ở lượt chat kế tiếp.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
from cowork_local.config import AppConfig # noqa: E402
|
||||||
|
from cowork_local.presentation.chat.chat_helpers import clip_chars # noqa: E402
|
||||||
|
from cowork_local.state import AppContext # noqa: E402
|
||||||
|
|
||||||
|
pytest.importorskip("PySide6", reason="Qt is required for the integration suite")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def qt_app():
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cowork(qt_app, tmp_path: Path):
|
||||||
|
from cowork_local.ui.cowork_tab import CoworkTab
|
||||||
|
|
||||||
|
tab = CoworkTab(AppContext(AppConfig.load(tmp_path / "config.json")))
|
||||||
|
yield tab
|
||||||
|
tab.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def test_clip_chars_keeps_ten_characters():
|
||||||
|
assert clip_chars("Tên ngắn") == "Tên ngắn"
|
||||||
|
assert clip_chars("0123456789") == "0123456789"
|
||||||
|
assert clip_chars("0123456789AB") == "0123456789…"
|
||||||
|
assert clip_chars("Dự án mới toanh") == "Dự án mới…" # không để khoảng trắng trước "…"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_of_open_conversation_updates_header(cowork):
|
||||||
|
cowork.load_conversation({"session_id": "s1", "title": "Tên cũ", "messages": []})
|
||||||
|
assert cowork._title_lbl.text() == "Tên cũ"
|
||||||
|
|
||||||
|
cowork.apply_renamed_title("s1", "Tên mới")
|
||||||
|
|
||||||
|
assert cowork.title == "Tên mới" # lượt chat sau lưu bằng tên mới
|
||||||
|
assert cowork._title_lbl.text() == "Tên mới"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_of_other_conversation_is_ignored(cowork):
|
||||||
|
cowork.load_conversation({"session_id": "s1", "title": "Đang mở", "messages": []})
|
||||||
|
cowork.apply_renamed_title("s2", "Khác")
|
||||||
|
assert cowork._title_lbl.text() == "Đang mở"
|
||||||
|
|
||||||
|
|
||||||
|
def test_long_title_is_clipped_with_full_tooltip(cowork):
|
||||||
|
long_title = "Báo cáo doanh thu quý 3"
|
||||||
|
cowork.load_conversation({"session_id": "s1", "title": "x", "messages": []})
|
||||||
|
cowork.apply_renamed_title("s1", long_title)
|
||||||
|
assert cowork._title_lbl.text() == "Báo cáo do…"
|
||||||
|
assert cowork._title_lbl.toolTip() == long_title
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_list_clips_title_to_ten_chars(qt_app, tmp_path):
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
|
from cowork_local.core.history import save_conversation
|
||||||
|
from cowork_local.ui.sidebar import HistorySidebar
|
||||||
|
|
||||||
|
ctx = AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||||
|
ctx.config.data.setdefault("history", {})["custom_dir"] = str(tmp_path / "history")
|
||||||
|
assert ctx.config.history_dir() == tmp_path / "history" # không đọc lịch sử thật
|
||||||
|
title = "Báo cáo doanh thu quý 3"
|
||||||
|
save_conversation(tmp_path / "history", "cowork", "s1",
|
||||||
|
[{"role": "user", "content": "hi"}], title, project_id="p-test")
|
||||||
|
sb = HistorySidebar(ctx)
|
||||||
|
sb._project_filter = "p-test" # chỉ đọc history_dir() tạm, không gộp các project thật
|
||||||
|
sb.refresh()
|
||||||
|
items = []
|
||||||
|
stack = [sb.tree.topLevelItem(i) for i in range(sb.tree.topLevelItemCount())]
|
||||||
|
while stack:
|
||||||
|
it = stack.pop()
|
||||||
|
if it.data(0, Qt.UserRole):
|
||||||
|
items.append(it)
|
||||||
|
stack.extend(it.child(i) for i in range(it.childCount()))
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0].text(0).splitlines()[0] == "Báo cáo do…"
|
||||||
|
assert items[0].toolTip(0) == title
|
||||||
|
assert items[0].data(0, Qt.UserRole + 3) == title # đổi tên vẫn điền tên đầy đủ
|
||||||
|
sb.deleteLater()
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""crash_guard ghi lại exception chưa bắt và phát hiện giao diện bị treo."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
pytest.importorskip("PySide6")
|
||||||
|
|
||||||
|
from cowork_local.presentation.shell import crash_guard # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def qt_app():
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def guard(tmp_path, monkeypatch):
|
||||||
|
import faulthandler
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PySide6.QtCore import qInstallMessageHandler
|
||||||
|
|
||||||
|
monkeypatch.setattr(sys, "excepthook", sys.excepthook)
|
||||||
|
monkeypatch.setattr(threading, "excepthook", threading.excepthook)
|
||||||
|
crash_guard.install(tmp_path)
|
||||||
|
yield tmp_path
|
||||||
|
qInstallMessageHandler(None)
|
||||||
|
faulthandler.disable()
|
||||||
|
crash_guard._crash_file.close()
|
||||||
|
crash_guard._crash_file = None
|
||||||
|
crash_guard._log_dir = None
|
||||||
|
|
||||||
|
|
||||||
|
def test_uncaught_thread_exception_is_logged_not_fatal(guard):
|
||||||
|
def boom():
|
||||||
|
raise ValueError("lỗi luồng nền")
|
||||||
|
|
||||||
|
t = threading.Thread(target=boom, name="worker-x")
|
||||||
|
t.start()
|
||||||
|
t.join()
|
||||||
|
log = (guard / "crash.log").read_text(encoding="utf-8")
|
||||||
|
assert "ValueError: lỗi luồng nền" in log
|
||||||
|
assert "worker-x" in log
|
||||||
|
|
||||||
|
|
||||||
|
def test_watchdog_dumps_stacks_and_pops_up_while_gui_is_stalled(guard, qt_app, monkeypatch):
|
||||||
|
monkeypatch.setattr(crash_guard, "HANG_SECONDS", 0.6)
|
||||||
|
events = []
|
||||||
|
monkeypatch.setattr(crash_guard.HangWatchdog, "_show_popup", lambda self: events.append("show"))
|
||||||
|
monkeypatch.setattr(crash_guard.HangWatchdog, "_close_popup", lambda self: events.append("close"))
|
||||||
|
dog = crash_guard.HangWatchdog("t")
|
||||||
|
try:
|
||||||
|
qt_app.processEvents()
|
||||||
|
time.sleep(1.5) # luồng giao diện "đứng"
|
||||||
|
assert events == ["show"]
|
||||||
|
end = time.time() + 2
|
||||||
|
while "close" not in events and time.time() < end:
|
||||||
|
qt_app.processEvents() # giao diện chạy lại
|
||||||
|
time.sleep(0.05)
|
||||||
|
assert events == ["show", "close"]
|
||||||
|
finally:
|
||||||
|
dog.stop()
|
||||||
|
assert "giao diện đứng" in (guard / "hang.log").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("lang, expected", [
|
||||||
|
("vi", "Đang xử lý"), ("en", "Processing"), ("ja", "処理中"),
|
||||||
|
])
|
||||||
|
def test_popup_text_follows_the_current_app_language(qt_app, monkeypatch, lang, expected):
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
from cowork_local.i18n import get_language, set_language
|
||||||
|
|
||||||
|
shown = []
|
||||||
|
fake_user32 = types.SimpleNamespace(MessageBoxW=lambda h, msg, title, f: shown.append(msg))
|
||||||
|
monkeypatch.setattr(sys, "platform", "win32")
|
||||||
|
monkeypatch.setitem(sys.modules, "ctypes", types.SimpleNamespace(
|
||||||
|
windll=types.SimpleNamespace(user32=fake_user32)))
|
||||||
|
before = get_language()
|
||||||
|
dog = crash_guard.HangWatchdog("t")
|
||||||
|
try:
|
||||||
|
set_language(lang)
|
||||||
|
dog._show_popup()
|
||||||
|
dog._popup.join(1)
|
||||||
|
finally:
|
||||||
|
dog.stop()
|
||||||
|
set_language(before)
|
||||||
|
assert shown and expected in shown[0]
|
||||||
@@ -234,16 +234,16 @@ def test_mode_comes_from_the_workspace_when_not_pinned(ctx, app_service) -> None
|
|||||||
assert outcome.switched is True
|
assert outcome.switched is True
|
||||||
|
|
||||||
|
|
||||||
def test_fallback_mode_survives_a_round_trip_through_config(ctx) -> None:
|
def test_removed_modes_resolve_to_auto(ctx) -> None:
|
||||||
"""The new mode must be persistable, or the toggle could never select it."""
|
"""Off/Fallback were removed from the UI: a stored value resolves to Auto."""
|
||||||
ctx.config.set_routing_mode_for("cowork", "fallback")
|
for legacy in ("off", "fallback"):
|
||||||
|
ctx.config.set_routing_mode_for("cowork", legacy)
|
||||||
assert ctx.config.routing_mode_for("cowork") == "fallback"
|
assert ctx.config.routing_mode_for("cowork") == "auto"
|
||||||
assert ctx.project_routing_mode("cowork") == "fallback"
|
assert ctx.project_routing_mode("cowork") == "auto"
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_persisted_mode_degrades_to_off(ctx) -> None:
|
def test_unknown_persisted_mode_degrades_to_auto(ctx) -> None:
|
||||||
"""A hand-edited config must not enable routing by accident."""
|
"""A hand-edited config resolves to the default mode (Auto)."""
|
||||||
ctx.config.routing["surface_modes"]["cowork"] = "turbo"
|
ctx.config.routing["surface_modes"]["cowork"] = "turbo"
|
||||||
|
|
||||||
assert ctx.config.routing_mode_for("cowork") == "off"
|
assert ctx.config.routing_mode_for("cowork") == "auto"
|
||||||
|
|||||||
@@ -32,16 +32,29 @@ def _mk(ctx, name):
|
|||||||
def test_defaults_follow_global_when_no_override(ctx):
|
def test_defaults_follow_global_when_no_override(ctx):
|
||||||
a = _mk(ctx, "Alpha")
|
a = _mk(ctx, "Alpha")
|
||||||
ctx.active_project_id = a.project_id
|
ctx.active_project_id = a.project_id
|
||||||
# Global default switch_mode is "off".
|
# Global default switch_mode is "auto" (Off was removed).
|
||||||
assert ctx.project_routing_mode("cowork") == "off"
|
assert ctx.project_routing_mode("cowork") == "auto"
|
||||||
# Change the GLOBAL default → project with no override follows it.
|
# Change the GLOBAL default → project with no override follows it.
|
||||||
ctx.config.data["routing"]["switch_mode"] = "auto"
|
ctx.config.data["routing"]["switch_mode"] = "manual"
|
||||||
|
assert ctx.project_routing_mode("cowork") == "manual"
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_off_and_fallback_resolve_to_auto(ctx):
|
||||||
|
a = _mk(ctx, "Alpha")
|
||||||
|
ctx.active_project_id = a.project_id
|
||||||
|
ctx.config.data["routing"]["switch_mode"] = "manual"
|
||||||
|
for legacy in ("off", "fallback"):
|
||||||
|
a.routing_modes = {"cowork": legacy}
|
||||||
|
save_project(a)
|
||||||
|
assert ctx.project_routing_mode("cowork") == "auto"
|
||||||
|
ctx.set_project_routing_mode("cowork", "off")
|
||||||
assert ctx.project_routing_mode("cowork") == "auto"
|
assert ctx.project_routing_mode("cowork") == "auto"
|
||||||
|
|
||||||
|
|
||||||
def test_per_workspace_routing_is_isolated(ctx):
|
def test_per_workspace_routing_is_isolated(ctx):
|
||||||
a = _mk(ctx, "Alpha")
|
a = _mk(ctx, "Alpha")
|
||||||
b = _mk(ctx, "Beta")
|
b = _mk(ctx, "Beta")
|
||||||
|
ctx.config.data["routing"]["switch_mode"] = "manual"
|
||||||
|
|
||||||
ctx.active_project_id = a.project_id
|
ctx.active_project_id = a.project_id
|
||||||
ctx.set_project_routing_mode("cowork", "auto")
|
ctx.set_project_routing_mode("cowork", "auto")
|
||||||
@@ -49,16 +62,19 @@ def test_per_workspace_routing_is_isolated(ctx):
|
|||||||
|
|
||||||
# Switching to workspace B must NOT see A's override (falls back to global).
|
# Switching to workspace B must NOT see A's override (falls back to global).
|
||||||
ctx.active_project_id = b.project_id
|
ctx.active_project_id = b.project_id
|
||||||
assert ctx.project_routing_mode("cowork") == "off"
|
|
||||||
|
|
||||||
# B sets its own, independently.
|
|
||||||
ctx.set_project_routing_mode("cowork", "manual")
|
|
||||||
assert ctx.project_routing_mode("cowork") == "manual"
|
assert ctx.project_routing_mode("cowork") == "manual"
|
||||||
|
|
||||||
# A is unchanged.
|
# B sets its own, independently.
|
||||||
|
ctx.set_project_routing_mode("cowork", "auto")
|
||||||
ctx.active_project_id = a.project_id
|
ctx.active_project_id = a.project_id
|
||||||
|
ctx.set_project_routing_mode("cowork", "manual")
|
||||||
|
ctx.active_project_id = b.project_id
|
||||||
assert ctx.project_routing_mode("cowork") == "auto"
|
assert ctx.project_routing_mode("cowork") == "auto"
|
||||||
|
|
||||||
|
# A keeps its own.
|
||||||
|
ctx.active_project_id = a.project_id
|
||||||
|
assert ctx.project_routing_mode("cowork") == "manual"
|
||||||
|
|
||||||
|
|
||||||
def test_per_surface_isolated_within_a_workspace(ctx):
|
def test_per_surface_isolated_within_a_workspace(ctx):
|
||||||
a = _mk(ctx, "Alpha")
|
a = _mk(ctx, "Alpha")
|
||||||
@@ -68,7 +84,8 @@ def test_per_surface_isolated_within_a_workspace(ctx):
|
|||||||
# co4e untouched → global default.
|
# co4e untouched → global default.
|
||||||
assert ctx.project_routing_mode("cowork") == "auto"
|
assert ctx.project_routing_mode("cowork") == "auto"
|
||||||
assert ctx.project_routing_mode("ai_edit") == "manual"
|
assert ctx.project_routing_mode("ai_edit") == "manual"
|
||||||
assert ctx.project_routing_mode("co4e") == "off"
|
ctx.config.data["routing"]["switch_mode"] = "manual"
|
||||||
|
assert ctx.project_routing_mode("co4e") == "manual"
|
||||||
|
|
||||||
|
|
||||||
def test_routing_mode_persists_to_disk(ctx):
|
def test_routing_mode_persists_to_disk(ctx):
|
||||||
|
|||||||
@@ -88,12 +88,21 @@ def test_best_for_returns_strong(service):
|
|||||||
assert ranking.best.assessment.metadata.model_id == "strong-model"
|
assert ranking.best.assessment.metadata.model_id == "strong-model"
|
||||||
|
|
||||||
|
|
||||||
def test_route_off_never_switches(service):
|
def test_route_off_explicit_override_never_switches(service):
|
||||||
|
# "off" is no longer user-selectable, but the engine still honours it
|
||||||
|
# when passed explicitly.
|
||||||
|
service.reassess()
|
||||||
|
r = service.route("cowork", "Write a Python function", "anthropic", "weak-model",
|
||||||
|
mode_override="off")
|
||||||
|
assert r.mode == SwitchMode.OFF
|
||||||
|
assert r.should_switch is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_off_in_config_routes_as_auto(service):
|
||||||
service.reassess()
|
service.reassess()
|
||||||
service.ctx.config.data["routing"]["switch_mode"] = "off"
|
service.ctx.config.data["routing"]["switch_mode"] = "off"
|
||||||
r = service.route("cowork", "Write a Python function", "anthropic", "weak-model")
|
r = service.route("cowork", "Write a Python function", "anthropic", "weak-model")
|
||||||
assert r.mode == SwitchMode.OFF
|
assert r.mode == SwitchMode.AUTO
|
||||||
assert r.should_switch is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_route_auto_switches_to_strong(service):
|
def test_route_auto_switches_to_strong(service):
|
||||||
@@ -147,12 +156,12 @@ def test_route_never_raises_on_broken_store(ctx, tmp_path):
|
|||||||
|
|
||||||
def test_per_surface_mode_override(service):
|
def test_per_surface_mode_override(service):
|
||||||
service.reassess()
|
service.reassess()
|
||||||
service.ctx.config.data["routing"]["switch_mode"] = "off"
|
service.ctx.config.data["routing"]["switch_mode"] = "manual"
|
||||||
service.ctx.config.data["routing"]["surface_modes"]["co4e"] = "auto"
|
service.ctx.config.data["routing"]["surface_modes"]["co4e"] = "auto"
|
||||||
# cowork follows global (off); co4e overridden to auto
|
# cowork follows global (manual); co4e overridden to auto
|
||||||
r_cowork = service.route("cowork", "Write a Python function", "anthropic", "weak-model")
|
r_cowork = service.route("cowork", "Write a Python function", "anthropic", "weak-model")
|
||||||
r_co4e = service.route("co4e", "Write a Python function", "anthropic", "weak-model")
|
r_co4e = service.route("co4e", "Write a Python function", "anthropic", "weak-model")
|
||||||
assert r_cowork.mode == SwitchMode.OFF
|
assert r_cowork.mode == SwitchMode.MANUAL
|
||||||
assert r_co4e.mode == SwitchMode.AUTO
|
assert r_co4e.mode == SwitchMode.AUTO
|
||||||
assert r_co4e.should_switch is True
|
assert r_co4e.should_switch is True
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,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)
|
lbl = getattr(self, "_title_lbl", None)
|
||||||
if lbl is None:
|
if lbl is None:
|
||||||
return # ChatPanel.__init__ sets self.title before we exist
|
return # ChatPanel.__init__ sets self.title before we exist
|
||||||
lbl.setText(getattr(self, "title", "") or tr("cowork.title"))
|
self.set_title_label(lbl, tr("cowork.title"))
|
||||||
|
|
||||||
def _retranslate(self) -> None:
|
def _retranslate(self) -> None:
|
||||||
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề và các nút trên thanh công cụ."""
|
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề và các nút trên thanh công cụ."""
|
||||||
|
|||||||
+7
-10
@@ -40,7 +40,7 @@ class RoutingToggle(QWidget):
|
|||||||
Emits :attr:`mode_changed`; call :meth:`refresh` after the workspace switches.
|
Emits :attr:`mode_changed`; call :meth:`refresh` after the workspace switches.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
mode_changed = Signal(str) # "off" | "auto" | "manual" | "fallback"
|
mode_changed = Signal(str) # "auto" | "manual"
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -76,14 +76,11 @@ class RoutingToggle(QWidget):
|
|||||||
# it the width stays frozen at the language the widget was built in and
|
# it the width stays frozen at the language the widget was built in and
|
||||||
# the longer translation is cut off.
|
# the longer translation is cut off.
|
||||||
self._combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
self._combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
||||||
# (data value, i18n key) — data is the persisted mode string. Order is
|
# (data value, i18n key) — data is the persisted mode string. Off and
|
||||||
# least-to-most autonomous, with Fallback (R03-T03) last because it is
|
# Fallback were dropped: only Auto and Manual remain selectable.
|
||||||
# the "only when something breaks" mode rather than a stronger Auto.
|
|
||||||
self._modes = [
|
self._modes = [
|
||||||
("off", "routing.mode_off"),
|
|
||||||
("auto", "routing.mode_auto"),
|
("auto", "routing.mode_auto"),
|
||||||
("manual", "routing.mode_manual"),
|
("manual", "routing.mode_manual"),
|
||||||
("fallback", "routing.mode_fallback"),
|
|
||||||
]
|
]
|
||||||
for value, key in self._modes:
|
for value, key in self._modes:
|
||||||
self._combo.addItem(tr(key), value)
|
self._combo.addItem(tr(key), value)
|
||||||
@@ -100,16 +97,16 @@ class RoutingToggle(QWidget):
|
|||||||
on_language_changed(self.retranslate)
|
on_language_changed(self.retranslate)
|
||||||
|
|
||||||
def current_mode(self) -> str:
|
def current_mode(self) -> str:
|
||||||
"""Chế độ định tuyến đang chọn; 'off' nếu chưa đặt."""
|
"""Chế độ định tuyến đang chọn; 'auto' nếu chưa đặt."""
|
||||||
return self._combo.currentData() or "off"
|
return self._combo.currentData() or "auto"
|
||||||
|
|
||||||
def refresh(self) -> None:
|
def refresh(self) -> None:
|
||||||
"""Re-read the backing mode (e.g. after switching workspace) and show it
|
"""Re-read the backing mode (e.g. after switching workspace) and show it
|
||||||
without emitting a spurious change."""
|
without emitting a spurious change."""
|
||||||
try:
|
try:
|
||||||
mode = self._get_mode() or "off"
|
mode = self._get_mode() or "auto"
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
mode = "off"
|
mode = "auto"
|
||||||
idx = self._combo.findData(mode)
|
idx = self._combo.findData(mode)
|
||||||
if idx < 0:
|
if idx < 0:
|
||||||
idx = 0
|
idx = 0
|
||||||
|
|||||||
+7
-1
@@ -14,6 +14,7 @@ from ..core.history import (
|
|||||||
rename_conversation, set_pinned,
|
rename_conversation, set_pinned,
|
||||||
)
|
)
|
||||||
from ..i18n import on_language_changed, tr
|
from ..i18n import on_language_changed, tr
|
||||||
|
from ..presentation.chat.chat_helpers import clip_chars
|
||||||
from .dialog_buttons import ask_text, confirm
|
from .dialog_buttons import ask_text, confirm
|
||||||
from .icons import collapse_left_icon, dot_icon, DOT_BLUE, icon
|
from .icons import collapse_left_icon, dot_icon, DOT_BLUE, icon
|
||||||
from .widgets import CollapseStrip
|
from .widgets import CollapseStrip
|
||||||
@@ -81,6 +82,7 @@ class HistorySidebar(QWidget):
|
|||||||
expand_requested = Signal() # strip clicked: re-expand
|
expand_requested = Signal() # strip clicked: re-expand
|
||||||
refresh_requested = Signal() # Refresh button: re-list + re-sync agent status
|
refresh_requested = Signal() # Refresh button: re-list + re-sync agent status
|
||||||
history_changed = Signal() # a conversation was deleted — other views (Project tab) should re-sync
|
history_changed = Signal() # a conversation was deleted — other views (Project tab) should re-sync
|
||||||
|
conversation_renamed = Signal(str, str) # session_id, new title — the open chat retitles itself
|
||||||
|
|
||||||
def __init__(self, ctx: AppContext):
|
def __init__(self, ctx: AppContext):
|
||||||
"""Cột lịch sử hội thoại.
|
"""Cột lịch sử hội thoại.
|
||||||
@@ -273,7 +275,9 @@ class HistorySidebar(QWidget):
|
|||||||
is_current = bool(sid) and sid == self.current_session_id
|
is_current = bool(sid) and sid == self.current_session_id
|
||||||
is_running = sid in self.running_ids
|
is_running = sid in self.running_ids
|
||||||
suffix = tr("sidebar.running_suffix") if is_running else ""
|
suffix = tr("sidebar.running_suffix") if is_running else ""
|
||||||
item = QTreeWidgetItem([f"{title}{suffix}\n{created}"])
|
# Tối đa 10 ký tự như tiêu đề khung chat; tên đầy đủ ở tooltip.
|
||||||
|
item = QTreeWidgetItem([f"{clip_chars(title)}{suffix}\n{created}"])
|
||||||
|
item.setToolTip(0, title)
|
||||||
# A running turn (blue LED) takes visual priority over the pin icon.
|
# A running turn (blue LED) takes visual priority over the pin icon.
|
||||||
if is_running:
|
if is_running:
|
||||||
item.setIcon(0, dot_icon(DOT_BLUE))
|
item.setIcon(0, dot_icon(DOT_BLUE))
|
||||||
@@ -352,6 +356,8 @@ class HistorySidebar(QWidget):
|
|||||||
if ok and new.strip():
|
if ok and new.strip():
|
||||||
rename_conversation(path, new.strip())
|
rename_conversation(path, new.strip())
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
sid = load_conversation(path).get("session_id", "")
|
||||||
|
self.conversation_renamed.emit(sid, new.strip())
|
||||||
elif chosen == del_act:
|
elif chosen == del_act:
|
||||||
if confirm(self, tr("sidebar.delete.title"),
|
if confirm(self, tr("sidebar.delete.title"),
|
||||||
tr("sidebar.delete.confirm", title=title)):
|
tr("sidebar.delete.confirm", title=title)):
|
||||||
|
|||||||
@@ -339,6 +339,8 @@ class WorkspaceTab(ProjectEditingMixin, ProjectFolderRuleMixin, QWidget):
|
|||||||
sb.expand_requested.connect(lambda: self._on_history_fold(False))
|
sb.expand_requested.connect(lambda: self._on_history_fold(False))
|
||||||
sb.refresh_requested.connect(self._on_sidebar_refresh)
|
sb.refresh_requested.connect(self._on_sidebar_refresh)
|
||||||
sb.history_changed.connect(self._reload_threads)
|
sb.history_changed.connect(self._reload_threads)
|
||||||
|
if self._cowork is not None:
|
||||||
|
sb.conversation_renamed.connect(self._cowork.apply_renamed_title)
|
||||||
|
|
||||||
def _on_history_fold(self, collapsed: bool) -> None:
|
def _on_history_fold(self, collapsed: bool) -> None:
|
||||||
"""Its chevron closes the panel away, back to the drawn layout."""
|
"""Its chevron closes the panel away, back to the drawn layout."""
|
||||||
|
|||||||
Reference in New Issue
Block a user