refactor(dashboard): R08-T13 — dashboard_tab.py 438 -> 215, tách 3 widget

token_usage_card_widget.py    93   5 thẻ số liệu + thẻ Ngân sách
    usage_chart_widget.py        119   biểu đồ tuần/tháng/năm + đường so sánh
    habits_widget.py             171   thói quen dùng token + nhận xét của AI

Ba widget THẬT, không phải mixin — khác với shell và Co4E, ba mảng này tách
bạch trên màn hình và không đọc state của nhau. Giao tiếp bằng signal:
budget_applied, filter_changed, status_message.

Điểm cần biết: các nút lật khoảng và hai ô chọn thuộc về UsageChartWidget
nhưng được Dashboard nhấc lên hàng điều khiển ở trên. Chúng là control của
biểu đồ, chỉ hiển thị ở chỗ khác.

Giữ 18 cầu tương thích cho tên cũ vì check_dashboard, check_design_parity và
check_controls_alive đọc thẳng self.card_total, self._chart_period_lbl...

756 test xanh. check_dashboard, check_design_parity, check_controls_alive qua.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nam Pham Dinh Thanh
2026-08-27 22:05:48 +09:00
co-authored by Claude Opus 5
parent f8e22f5f5b
commit 062ea4ba21
4 changed files with 509 additions and 345 deletions
+171
View File
@@ -0,0 +1,171 @@
"""Phần thói quen dùng token, và nhận xét của AI — R08-T13.
Hai phần chồng lên nhau:
* **Tóm tắt thói quen** — dựng tại chỗ từ số liệu đã gộp: việc nào tốn token
nhất, chia theo nguồn, trung bình mỗi lượt, ngày và giờ bận nhất.
* **Nhận xét của AI** — chỉ chạy khi người dùng bấm. Gửi đi **các con số đã
gộp**, không bao giờ gửi nội dung câu chat gốc; đó là ranh giới cố ý.
Nút "áp dụng chiến lược tiết kiệm" chỉ hiện sau khi có nhận xét, và vẫn hỏi
lại trước khi đổi cấu hình — nó bật nén tự động và hạ ngưỡng nén, tức là đổi
hành vi của mọi lượt chat sau đó.
"""
from __future__ import annotations
from typing import List
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QHBoxLayout, QLabel, QMessageBox, QPushButton, QTextBrowser, QVBoxLayout,
QWidget,
)
from ...core import usage_tracker as ut
from ...core.worker import AgentWorker
from ...i18n import tr
from ...ui.icons import icon
from ...ui.widgets import fmt_tokens as _fmt_tokens
class HabitsWidget(QWidget):
#: Có gì cần nói với người dùng (lỗi, đã áp dụng xong…).
status_message = Signal(str)
#: Người dùng đồng ý áp dụng chiến lược tiết kiệm.
strategy_approved = Signal()
def __init__(self, ctx, parent=None):
super().__init__(parent)
self.ctx = ctx
self._worker = None
v = QVBoxLayout(self)
v.setContentsMargins(0, 0, 0, 0)
head = QHBoxLayout()
self.title = QLabel()
self.title.setStyleSheet("font-weight:600;")
self.apply_strategy_btn = QPushButton()
self.apply_strategy_btn.setIcon(icon("bolt"))
self.apply_strategy_btn.setVisible(False)
self.apply_strategy_btn.clicked.connect(self._ap_dung)
self.ai_analyze_btn = QPushButton()
self.ai_analyze_btn.setIcon(icon("sparkle"))
self.ai_analyze_btn.clicked.connect(self.phan_tich)
head.addWidget(self.title, 1)
head.addWidget(self.apply_strategy_btn)
head.addWidget(self.ai_analyze_btn)
v.addLayout(head)
self.habits = QTextBrowser()
self.habits.setOpenExternalLinks(False)
v.addWidget(self.habits)
self.ai_title = QLabel()
self.ai_title.setStyleSheet("font-weight:600;")
self.ai_title.setVisible(False)
v.addWidget(self.ai_title)
self.ai_advice = QTextBrowser()
self.ai_advice.setVisible(False)
v.addWidget(self.ai_advice)
# ---- tóm tắt ---------------------------------------------------------
def set_summary(self, s: dict, co_du_lieu: bool) -> None:
lines: List[str] = []
if not co_du_lieu:
lines.append("<i>%s</i>" % tr("dashboard.no_data"))
self.habits.setHtml("".join(lines))
return
lines.append("<b>%s</b>" % tr("dashboard.h_top"))
lines.append("<ol>")
for label, tok in s["top_labels"]:
pct = int(tok * 100 / s["total"]) if s["total"] else 0
lines.append("<li>%s — %s tokens (%d%%)</li>"
% (label[:60], _fmt_tokens(tok), pct))
lines.append("</ol>")
src_parts = ", ".join(
"%s: %s" % (tr("app.tab.%s" % k) if k in ("cowork", "code") else k,
_fmt_tokens(v))
for k, v in s["by_source"])
lines.append("<b>%s</b>: %s<br>" % (tr("dashboard.h_by_source"), src_parts))
lines.append("<b>%s</b>: %s tokens<br>"
% (tr("dashboard.h_avg"), _fmt_tokens(s["avg_per_turn"])))
if s["busiest_day"]:
lines.append("<b>%s</b>: %s<br>"
% (tr("dashboard.h_busiest_day"), s["busiest_day"]))
if s["busiest_hour"] is not None:
h = s["busiest_hour"]
lines.append("<b>%s</b>: %02d:00–%02d:59<br>"
% (tr("dashboard.h_busiest_hour"), h, h))
if s["estimated_share"] > 0:
lines.append("<i>%s</i>" % tr("dashboard.estimated_note",
pct=int(s["estimated_share"] * 100)))
self.habits.setHtml("".join(lines))
# ---- nhận xét của AI -------------------------------------------------
def phan_tich(self) -> None:
"""Gửi CÁC CON SỐ ĐÃ GỘP (không bao giờ gửi nội dung chat) cho provider
đang chọn, rồi hiện nhận xét về thói quen và cách tiết kiệm token."""
if self._worker is not None:
return
events = ut.load_events(*self._khoang())
if not events:
self.status_message.emit(tr("dashboard.no_data"))
return
summary = ut.summarize(events)
self.ai_analyze_btn.setEnabled(False)
self.ai_analyze_btn.setText(tr("dashboard.ai_analyzing"))
ctx = self.ctx
def job(worker: AgentWorker):
from ...i18n import get_language
prompt = ut.build_ai_analysis_prompt(summary, get_language())
reply = ctx.build_active_provider().chat(
[{"role": "user", "content": prompt}], cancel=worker.stop_event)
return {"text": (reply.get("content") or "").strip()}
def done(result: dict) -> None:
self._xong()
text = result.get("text") or ""
if text:
self.ai_title.setText(tr("dashboard.ai_advice_title"))
self.ai_title.setVisible(True)
self.ai_advice.setMarkdown(text)
self.ai_advice.setVisible(True)
self.apply_strategy_btn.setVisible(True)
def failed(err: str) -> None:
self._xong()
self.status_message.emit(str(err))
w = AgentWorker(job)
w.finished_ok.connect(done)
w.failed.connect(failed)
self._worker = w
w.start()
def _xong(self) -> None:
self._worker = None
self.ai_analyze_btn.setEnabled(True)
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
#: Dashboard gán hàm trả về (start, end) của khoảng đang lọc.
_khoang = staticmethod(lambda: (None, None))
# ---- áp dụng chiến lược tiết kiệm ------------------------------------
def _ap_dung(self) -> None:
if QMessageBox.question(self, tr("dashboard.strategy_title"),
tr("dashboard.strategy_confirm")) != QMessageBox.Yes:
return
cx = self.ctx.config.data.setdefault("context", {})
cx["auto_compact"] = True
cx["compact_threshold"] = 0.6 # nén ở 60% cửa sổ (trước ~80%)
cx["compress_before_send"] = True # gộp ngữ cảnh trước mỗi lượt
self.ctx.save()
self.strategy_approved.emit()
self.status_message.emit(tr("dashboard.strategy_applied"))