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:
co-authored by
Claude Opus 5
parent
f8e22f5f5b
commit
062ea4ba21
@@ -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"))
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Hàng thẻ số liệu trên đầu Dashboard — R08-T13.
|
||||
|
||||
Năm con số (tổng / vào / ra / cache / chi phí) cộng thẻ Ngân sách.
|
||||
|
||||
Bố cục không phải sáu ô bằng nhau: chi phí là con số màn hình này sinh ra để
|
||||
trả lời, nên nó chiếm một thẻ cao gấp đôi bên trái, bốn con số phụ xếp 2×2
|
||||
bên cạnh. Sáu ô bằng nhau thì không có gì nói cho người dùng biết cái nào
|
||||
đáng nhìn trước.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QGridLayout, QWidget
|
||||
|
||||
from ...core import usage_tracker as ut
|
||||
from ...i18n import tr
|
||||
from ...ui.icons import icon
|
||||
from ...ui.widgets import BudgetCard, StatCard
|
||||
from ...ui.widgets import fmt_tokens as _fmt_tokens
|
||||
|
||||
|
||||
class TokenUsageCardWidget(QWidget):
|
||||
#: Người dùng bấm Áp dụng trên thẻ Ngân sách. Widget không tự ghi cấu hình
|
||||
#: — nó chỉ báo; ai sở hữu cấu hình thì người đó ghi.
|
||||
budget_applied = Signal(float)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
grid = QGridLayout(self)
|
||||
grid.setContentsMargins(0, 0, 0, 0)
|
||||
grid.setSpacing(8)
|
||||
|
||||
self.card_total = StatCard()
|
||||
self.card_in = StatCard()
|
||||
self.card_out = StatCard()
|
||||
self.card_cache = StatCard()
|
||||
self.card_cost = StatCard().as_hero()
|
||||
|
||||
# Thẻ chi phí bên trái, chiếm cả hai hàng; bốn con số phụ lấp khối 2×2.
|
||||
grid.addWidget(self.card_cost, 0, 0, 2, 1)
|
||||
for i, card in enumerate((self.card_total, self.card_in,
|
||||
self.card_out, self.card_cache)):
|
||||
grid.addWidget(card, i // 2, 1 + i % 2)
|
||||
|
||||
self.budget_card = BudgetCard()
|
||||
self.budget_card.apply_btn.setIcon(icon("check"))
|
||||
self.budget_card.apply_btn.clicked.connect(
|
||||
lambda: self.budget_applied.emit(self.budget_card.budget_spin.value()))
|
||||
grid.addWidget(self.budget_card, 0, 3, 2, 1)
|
||||
|
||||
# Cột thẻ chi phí và cột Ngân sách rộng hơn bốn ô nhỏ.
|
||||
for col, stretch in ((0, 3), (1, 2), (2, 2), (3, 3)):
|
||||
grid.setColumnStretch(col, stretch)
|
||||
|
||||
# ---- nạp số liệu -----------------------------------------------------
|
||||
|
||||
def set_usage(self, s: dict, costs: dict, pricing: dict) -> None:
|
||||
est_note = (tr("dashboard.estimated_note", pct=int(s["estimated_share"] * 100))
|
||||
if s["estimated_share"] > 0 else "")
|
||||
self.card_total.set(tr("dashboard.card_total"), _fmt_tokens(s["total"]),
|
||||
tr("dashboard.card_turns", n=s["turns"]))
|
||||
self.card_in.set(tr("dashboard.card_in"), _fmt_tokens(s["in"]),
|
||||
ut.format_cost(costs["in"], pricing))
|
||||
self.card_out.set(tr("dashboard.card_out"), _fmt_tokens(s["out"]),
|
||||
ut.format_cost(costs["out"], pricing))
|
||||
self.card_cache.set(tr("dashboard.card_cache"), _fmt_tokens(s["cache"]),
|
||||
ut.format_cost(costs["cache"], pricing))
|
||||
self.card_cost.set(tr("dashboard.card_cost"),
|
||||
ut.format_cost(sum(costs.values()), pricing, digits=2),
|
||||
est_note)
|
||||
|
||||
def set_budget(self, status, pricing: dict, convert) -> None:
|
||||
"""``status`` là None khi người dùng chưa đặt ngân sách nào."""
|
||||
if status is None:
|
||||
self.budget_card.set(tr("usage.budget_title"), "—",
|
||||
tr("usage.budget_no_budget"))
|
||||
self.budget_card.budget_spin.setValue(0.0)
|
||||
return
|
||||
|
||||
ccy = pricing.get("currency", "USD")
|
||||
value = ("%s / %s" % (ut.format_cost(status["remaining_usd"], pricing, digits=2),
|
||||
ut.format_cost(status["amount_usd"], pricing, digits=2)))
|
||||
pct = int(round(status["pct_used"] * 100))
|
||||
sub = (tr("usage.budget_over_warning") if status["over_85"]
|
||||
else tr("usage.budget_used_pct", pct=pct))
|
||||
self.budget_card.set(tr("usage.budget_title"), value, sub,
|
||||
warn=status["over_85"])
|
||||
|
||||
# Ô nhập giữ giá trị ngân sách HIỆN TẠI, nhưng không giẫm lên thứ người
|
||||
# dùng đang gõ dở.
|
||||
if not self.budget_card.budget_spin.hasFocus():
|
||||
self.budget_card.budget_spin.setValue(
|
||||
round(convert(status["amount_usd"], "USD", ccy), 2))
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Biểu đồ mức dùng theo khoảng thời gian — R08-T13.
|
||||
|
||||
Cắt khoảng đang chọn thành từng phần: TUẦN → 7 ngày (T2–CN) · THÁNG → các
|
||||
tuần W1…Wn · NĂM → 12 tháng.
|
||||
|
||||
Đường nét đứt là mốc so sánh với khoảng liền trước cùng loại — "tuần trước" ở
|
||||
chế độ tuần, "tháng trước" ở chế độ tháng. Nó vẽ ở mức trung bình mỗi điểm của
|
||||
khoảng trước để nằm đúng thang đo, còn nhãn thì hiện % thay đổi của tổng.
|
||||
|
||||
Các nút lật khoảng và hai ô chọn nằm ở đây nhưng được đặt lên hàng điều khiển
|
||||
của Dashboard — chúng thuộc về biểu đồ, chỉ hiển thị ở chỗ khác.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ...core import usage_tracker as ut
|
||||
from ...i18n import tr
|
||||
from ...theme import current_palette
|
||||
from ...ui.icons import icon
|
||||
from ...ui.spline_chart import SplineChart
|
||||
from ...ui.widgets import fmt_tokens as _fmt_tokens
|
||||
|
||||
_REF_KEY = {"week": "dashboard.ref_last_week",
|
||||
"month": "dashboard.ref_last_month",
|
||||
"year": "dashboard.ref_last_year"}
|
||||
|
||||
|
||||
class UsageChartWidget(QWidget):
|
||||
#: Người dùng đổi khoảng/độ mịn/chỉ số — Dashboard nạp lại cả màn, không
|
||||
#: riêng biểu đồ, vì thẻ số liệu cũng đi theo bộ lọc.
|
||||
filter_changed = Signal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.offset = 0 # 0 = khoảng hiện tại; số âm = lùi về trước
|
||||
|
||||
# --- các control, sẽ được Dashboard nhấc lên hàng điều khiển ---
|
||||
self.prev_btn = QPushButton()
|
||||
self.prev_btn.setIcon(icon("chevron-left"))
|
||||
self.prev_btn.setFixedWidth(30)
|
||||
self.prev_btn.clicked.connect(self._lui)
|
||||
|
||||
self.period_lbl = QLabel()
|
||||
self.period_lbl.setObjectName("hint")
|
||||
self.period_lbl.setAlignment(Qt.AlignCenter)
|
||||
self.period_lbl.setMinimumWidth(170)
|
||||
|
||||
self.next_btn = QPushButton()
|
||||
self.next_btn.setIcon(icon("chevron-right"))
|
||||
self.next_btn.setFixedWidth(30)
|
||||
self.next_btn.clicked.connect(self._toi)
|
||||
|
||||
self.gran_combo = QComboBox()
|
||||
self.metric_combo = QComboBox()
|
||||
self.metric_combo.currentIndexChanged.connect(self.filter_changed)
|
||||
|
||||
# --- thân widget: tiêu đề + biểu đồ ---
|
||||
v = QVBoxLayout(self)
|
||||
v.setContentsMargins(0, 0, 0, 0)
|
||||
head = QHBoxLayout()
|
||||
self.title = QLabel()
|
||||
self.title.setStyleSheet("font-weight:600;")
|
||||
head.addWidget(self.title, 1)
|
||||
v.addLayout(head)
|
||||
self.chart = SplineChart()
|
||||
v.addWidget(self.chart)
|
||||
|
||||
# ---- lật khoảng ------------------------------------------------------
|
||||
|
||||
def _lui(self) -> None:
|
||||
self.offset -= 1
|
||||
self.filter_changed.emit()
|
||||
|
||||
def _toi(self) -> None:
|
||||
# Không cho đi quá khoảng hiện tại: tương lai thì chưa có dữ liệu.
|
||||
if self.offset < 0:
|
||||
self.offset += 1
|
||||
self.filter_changed.emit()
|
||||
|
||||
# ---- vẽ --------------------------------------------------------------
|
||||
|
||||
def refresh(self, events, pricing: dict) -> None:
|
||||
gran = self.gran_combo.currentData() or "week"
|
||||
metric = self.metric_combo.currentData() or "cost"
|
||||
parts = ut.period_breakdown(events, gran, pricing, offset=self.offset)
|
||||
|
||||
mi = 0 if metric == "tokens" else 1 # (nhãn, token, tiền)
|
||||
pts = [(row[0], float(row[mi + 1])) for row in parts]
|
||||
|
||||
# Dạng tiền rút gọn: hộp nhãn trục y hẹp, format_cost đầy đủ (tới 4 số
|
||||
# lẻ với USD) tràn ra ngoài và che mất con số.
|
||||
fmt = (_fmt_tokens if metric == "tokens"
|
||||
else (lambda v: ut.format_cost_compact(v, pricing)))
|
||||
|
||||
cur = ut.period_totals(events, gran, pricing, self.offset)
|
||||
prev = ut.period_totals(events, gran, pricing, self.offset - 1)
|
||||
refs = []
|
||||
if prev[mi] > 0:
|
||||
# Màu mờ có chủ ý: đây là mốc tham chiếu, không được tranh chấp
|
||||
# với đường dữ liệu màu nhấn.
|
||||
refs.append((prev[mi] / max(1, len(parts)),
|
||||
"%s %s" % (tr(_REF_KEY.get(gran, _REF_KEY["week"])),
|
||||
_delta(cur[mi], prev[mi])),
|
||||
current_palette().text_muted))
|
||||
self.chart.set_reference_lines(refs)
|
||||
self.chart.set_data(pts, fmt, tr("dashboard.metric_%s" % metric))
|
||||
self.period_lbl.setText(ut.period_range_label(gran, self.offset))
|
||||
self.next_btn.setEnabled(self.offset < 0)
|
||||
|
||||
|
||||
def _delta(cur: float, prev: float) -> str:
|
||||
if prev <= 0:
|
||||
return ""
|
||||
pct = (cur - prev) / prev * 100.0
|
||||
return "(%+.0f%%)" % pct
|
||||
Reference in New Issue
Block a user