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"))
@@ -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
+126 -345
View File
@@ -1,35 +1,33 @@
"""Dashboard tab — token usage & cost overview. """Màn Dashboard — khung lắp ráp (R08-T13).
Top: header (period filter + display-currency picker + refresh), then stat Ba mảng đã bóc sang ``presentation/dashboard/``:
cards (total, input, output, cache tokens, and cost per bucket). Unit prices
still come from Monitoring's model pricing table (same ``usage.*`` config keys token_usage_card_widget.py 5 thẻ số liệu + thẻ Ngân sách
— both screens always agree); the currency picker itself lives HERE, beside usage_chart_widget.py biểu đồ theo tuần / tháng / năm
refresh. Bottom: a habits summary — which tasks/sessions burn the most tokens, habits_widget.py thói quen dùng token + nhận xét của AI
average per prompt, busiest day/hour. Data comes from the local usage log (one
event per model turn, recorded by the providers — real server counts when File này còn ba việc: dựng hàng điều khiển (bộ lọc khoảng áp cho CẢ màn — thẻ,
available, ~4 chars/token estimates otherwise). biểu đồ và thói quen đều đi theo nó), nạp dữ liệu một lần rồi chia cho ba
widget, và tự làm mới mỗi 30 giây.
""" """
from __future__ import annotations from __future__ import annotations
from datetime import date, timedelta from datetime import timedelta
from typing import Dict, List, Optional from typing import Dict
from PySide6.QtCore import Qt, QTimer, Signal from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QGridLayout, QHBoxLayout, QLabel, QComboBox, QHBoxLayout, QLabel, QPushButton, QScrollArea, QVBoxLayout,
QPushButton, QScrollArea, QTextBrowser, QVBoxLayout, QWidget, QWidget,
) )
from ..core import usage_tracker as ut from ..core import usage_tracker as ut
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr from ..i18n import on_language_changed, tr
from ..presentation.dashboard.habits_widget import HabitsWidget
from ..presentation.dashboard.token_usage_card_widget import TokenUsageCardWidget
from ..presentation.dashboard.usage_chart_widget import UsageChartWidget
from ..state import AppContext from ..state import AppContext
from ..theme import current_palette
from .icons import icon from .icons import icon
from .spline_chart import SplineChart
from .widgets import BudgetCard as _BudgetCard
from .widgets import StatCard as _StatCard
from .widgets import fmt_tokens as _fmt_tokens
class DashboardTab(QWidget): class DashboardTab(QWidget):
@@ -40,7 +38,6 @@ class DashboardTab(QWidget):
def __init__(self, ctx: AppContext): def __init__(self, ctx: AppContext):
super().__init__() super().__init__()
self.ctx = ctx self.ctx = ctx
outer = QVBoxLayout(self) outer = QVBoxLayout(self)
scroll = QScrollArea() scroll = QScrollArea()
scroll.setWidgetResizable(True) scroll.setWidgetResizable(True)
@@ -50,35 +47,37 @@ class DashboardTab(QWidget):
outer.addWidget(scroll) outer.addWidget(scroll)
root = QVBoxLayout(content) root = QVBoxLayout(content)
# ---- header: title + the PERIOD FILTER (applies to the WHOLE dashboard — self.cards = TokenUsageCardWidget()
# cards, chart and habits all follow the selected week/month) + refresh self.chart_panel = UsageChartWidget()
self._chart_offset = 0 # 0 = current period; <0 = a past period self.habits_panel = HabitsWidget(ctx)
# ---- hàng tiêu đề ------------------------------------------------
head = QHBoxLayout() head = QHBoxLayout()
self._title = QLabel() self._title = QLabel()
self._title.setStyleSheet("font-weight:700; font-size:15px;") self._title.setStyleSheet("font-weight:700; font-size:15px;")
self.chart_prev_btn = QPushButton() self.refresh_btn = QPushButton("")
self.chart_prev_btn.setIcon(icon("chevron-left")) self.refresh_btn.setIcon(icon("refresh"))
self.chart_prev_btn.setFixedWidth(30) self.refresh_btn.setFixedWidth(34)
self.chart_prev_btn.clicked.connect(self._chart_prev) self.refresh_btn.clicked.connect(self.refresh)
self._chart_period_lbl = QLabel() head.addWidget(self._title, 1)
self._chart_period_lbl.setObjectName("hint") head.addWidget(self.refresh_btn)
self._chart_period_lbl.setAlignment(Qt.AlignCenter) root.addLayout(head)
self._chart_period_lbl.setMinimumWidth(170)
self.chart_next_btn = QPushButton() # ---- hàng điều khiển ---------------------------------------------
self.chart_next_btn.setIcon(icon("chevron-right")) # Hai hàng, gom theo việc control làm gì, thay vì chín widget xâu trên
self.chart_next_btn.setFixedWidth(30) # một dòng nơi tiêu đề, bộ lật ngày, hai ô chọn biểu đồ, ô chọn tiền tệ
self.chart_next_btn.clicked.connect(self._chart_next) # và nút Làm mới đọc thành một dải không phân biệt được.
self.gran_combo = QComboBox() # Hàng 1 là "tôi đang ở đâu"; hàng 2 là "tôi đang xem cái gì".
for g in ("week", "month", "year"): for g in ("week", "month", "year"):
self.gran_combo.addItem(tr(f"dashboard.gran_{g}"), g) self.chart_panel.gran_combo.addItem(tr("dashboard.gran_%s" % g), g)
self.gran_combo.currentIndexChanged.connect(self._on_gran_changed) self.chart_panel.gran_combo.currentIndexChanged.connect(self._on_gran_changed)
self.metric_combo = QComboBox()
for m in ("cost", "tokens"): for m in ("cost", "tokens"):
self.metric_combo.addItem(tr(f"dashboard.metric_{m}"), m) self.chart_panel.metric_combo.addItem(tr("dashboard.metric_%s" % m), m)
self.metric_combo.currentIndexChanged.connect(self._refresh_chart) self.chart_panel.filter_changed.connect(self.refresh)
# Display-currency picker — moved here from Monitoring's Token Usage
# card, right beside refresh; both screens still share the same # Ô chọn tiền hiển thị — dời từ thẻ Token Usage của Monitoring sang đây,
# usage.currency config key, so changing it here updates everywhere. # ngay cạnh Làm mới; hai màn vẫn dùng chung khoá usage.currency nên đổi
# ở đây là đổi cả hai nơi.
self.currency_lbl = QLabel() self.currency_lbl = QLabel()
self.currency_lbl.setObjectName("hint") self.currency_lbl.setObjectName("hint")
self.currency_combo = QComboBox() self.currency_combo = QComboBox()
@@ -88,102 +87,33 @@ class DashboardTab(QWidget):
(self.ctx.config.data.get("usage") or {}).get("currency", "USD")) (self.ctx.config.data.get("usage") or {}).get("currency", "USD"))
self.currency_combo.setCurrentIndex(max(0, idx)) self.currency_combo.setCurrentIndex(max(0, idx))
self.currency_combo.currentIndexChanged.connect(self._on_currency_changed) self.currency_combo.currentIndexChanged.connect(self._on_currency_changed)
self.refresh_btn = QPushButton("")
self.refresh_btn.setIcon(icon("refresh"))
self.refresh_btn.setFixedWidth(34)
self.refresh_btn.clicked.connect(self.refresh)
# Two rows, grouped by what the controls do, instead of nine widgets
# strung across one line where the title, a date pager, two chart
# selectors, a currency picker and Refresh all read as one undifferentiated
# strip. Row 1 is "where am I"; row 2 is "what am I looking at".
head.addWidget(self._title, 1)
head.addWidget(self.refresh_btn)
root.addLayout(head)
controls = QHBoxLayout() controls = QHBoxLayout()
controls.setSpacing(6) controls.setSpacing(6)
controls.addWidget(self.chart_prev_btn) # period pager controls.addWidget(self.chart_panel.prev_btn) # lật khoảng
controls.addWidget(self._chart_period_lbl) controls.addWidget(self.chart_panel.period_lbl)
controls.addWidget(self.chart_next_btn) controls.addWidget(self.chart_panel.next_btn)
controls.addSpacing(12) controls.addSpacing(12)
controls.addWidget(self.gran_combo) # what the chart plots controls.addWidget(self.chart_panel.gran_combo) # biểu đồ vẽ gì
controls.addWidget(self.metric_combo) controls.addWidget(self.chart_panel.metric_combo)
controls.addStretch(1) controls.addStretch(1)
controls.addWidget(self.currency_lbl) # how money is displayed controls.addWidget(self.currency_lbl) # tiền hiện thế nào
controls.addWidget(self.currency_combo) controls.addWidget(self.currency_combo)
root.addLayout(controls) root.addLayout(controls)
# ---- stat cards --------------------------------------------------- # ---- ba mảng nội dung --------------------------------------------
# Cost is the headline this screen exists for, so it gets a card twice root.addWidget(self.cards)
# the height of the rest instead of being the fifth of five identical root.addWidget(self.chart_panel)
# tiles — with six equal cards nothing said which number mattered. self.habits_panel.habits.setMinimumHeight(160)
cards_grid = QGridLayout() self.habits_panel.ai_advice.setMinimumHeight(140)
cards_grid.setSpacing(8) self.habits_panel.ai_advice.setOpenExternalLinks(False)
self.card_total = _StatCard() self.habits_panel._khoang = self._period_range
self.card_in = _StatCard() root.addWidget(self.habits_panel, 1)
self.card_out = _StatCard()
self.card_cache = _StatCard()
self.card_cost = _StatCard().as_hero()
# Hero on the left, spanning both rows; the four supporting figures fill
# a 2×2 block beside it.
cards_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)):
cards_grid.addWidget(card, i // 2, 1 + i % 2)
# Budget: remaining/budget, direct entry, auto-warns red past 85% used.
self.budget_card = _BudgetCard()
self.budget_card.apply_btn.setIcon(icon("check"))
self.budget_card.apply_btn.clicked.connect(self._apply_budget)
cards_grid.addWidget(self.budget_card, 0, 3, 2, 1)
# The hero and Budget columns get more room than the small tiles.
for col, stretch in ((0, 3), (1, 2), (2, 2), (3, 3)):
cards_grid.setColumnStretch(col, stretch)
root.addLayout(cards_grid)
# ---- token/cost within the selected period (spline): WEEK → 7 days self.cards.budget_applied.connect(self._apply_budget)
# (Mon–Sun) · MONTH → weeks W1…Wn · YEAR → 12 months. Dashed lines self.habits_panel.status_message.connect(self.status_message)
# compare the previous week / month. ----
chart_head = QHBoxLayout()
self._chart_title = QLabel()
self._chart_title.setStyleSheet("font-weight:600;")
chart_head.addWidget(self._chart_title, 1)
root.addLayout(chart_head)
self.chart = SplineChart()
root.addWidget(self.chart)
# ---- habits summary ------------------------------------------------- # Tự làm mới mỗi 30 giây để con số đi theo việc đang chạy.
self._habits_title = QLabel()
self._habits_title.setStyleSheet("font-weight:600;")
habits_head = QHBoxLayout()
self.ai_analyze_btn = QPushButton()
self.ai_analyze_btn.setIcon(icon("sparkle"))
self.ai_analyze_btn.clicked.connect(self._ai_analyze)
# Apply an AI-suggested cost-saving strategy (enable auto-compress + tune
# the compression threshold) — only after the user clicks to approve it.
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._apply_saving_strategy)
habits_head.addWidget(self._habits_title, 1)
habits_head.addWidget(self.apply_strategy_btn)
habits_head.addWidget(self.ai_analyze_btn)
root.addLayout(habits_head)
self.habits = QTextBrowser()
self.habits.setOpenExternalLinks(False)
self.habits.setMinimumHeight(160)
root.addWidget(self.habits, 1)
# AI recommendations panel (filled by the ✨ button).
self._ai_title = QLabel()
self._ai_title.setStyleSheet("font-weight:600;")
self._ai_title.setVisible(False)
root.addWidget(self._ai_title)
self.ai_advice = QTextBrowser()
self.ai_advice.setOpenExternalLinks(False)
self.ai_advice.setMinimumHeight(140)
self.ai_advice.setVisible(False)
root.addWidget(self.ai_advice, 1)
# Auto-refresh every 30s so numbers follow ongoing work.
self._timer = QTimer(self) self._timer = QTimer(self)
self._timer.setInterval(30_000) self._timer.setInterval(30_000)
self._timer.timeout.connect(self.refresh) self._timer.timeout.connect(self.refresh)
@@ -192,12 +122,47 @@ class DashboardTab(QWidget):
on_language_changed(self._retranslate) on_language_changed(self._retranslate)
self.refresh() self.refresh()
# ---- helpers ----------------------------------------------------------- # ---- cầu tương thích -------------------------------------------------
# Ba checker trong tools/ đọc thẳng tên cũ. Giữ nguyên đường vào; nơi ở
# thật của chúng nay là ba widget con.
card_total = property(lambda self: self.cards.card_total)
card_in = property(lambda self: self.cards.card_in)
card_out = property(lambda self: self.cards.card_out)
card_cache = property(lambda self: self.cards.card_cache)
card_cost = property(lambda self: self.cards.card_cost)
budget_card = property(lambda self: self.cards.budget_card)
chart = property(lambda self: self.chart_panel.chart)
gran_combo = property(lambda self: self.chart_panel.gran_combo)
metric_combo = property(lambda self: self.chart_panel.metric_combo)
chart_prev_btn = property(lambda self: self.chart_panel.prev_btn)
chart_next_btn = property(lambda self: self.chart_panel.next_btn)
_chart_period_lbl = property(lambda self: self.chart_panel.period_lbl)
_chart_title = property(lambda self: self.chart_panel.title)
_habits_title = property(lambda self: self.habits_panel.title)
_ai_title = property(lambda self: self.habits_panel.ai_title)
habits = property(lambda self: self.habits_panel.habits)
ai_advice = property(lambda self: self.habits_panel.ai_advice)
ai_analyze_btn = property(lambda self: self.habits_panel.ai_analyze_btn)
apply_strategy_btn = property(lambda self: self.habits_panel.apply_strategy_btn)
# ---- helpers ---------------------------------------------------------
def _pricing(self) -> Dict: def _pricing(self) -> Dict:
from ..core import model_pricing as mp from ..core import model_pricing as mp
mp.sync_to_usage(self.ctx.config) # cost/total comes straight from the price table mp.sync_to_usage(self.ctx.config) # tổng/chi phí lấy thẳng từ bảng giá
return {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})} return {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
def _period_range(self):
"""Khoảng đang chọn dạng (đầu, cuối) bao gồm cả hai đầu — nó điều khiển
cả màn: thẻ số liệu, biểu đồ và thói quen."""
gran = self.chart_panel.gran_combo.currentData() or "week"
start, end = ut.period_bounds(gran, self.chart_panel.offset)
return start, end - timedelta(days=1) # load_events tính cả ngày cuối
def _on_gran_changed(self, *_a) -> None:
self.chart_panel.offset = 0 # đổi độ mịn → quay về khoảng hiện tại
self.refresh() # bộ lọc điều khiển CẢ màn
def _on_currency_changed(self, _idx: int) -> None: def _on_currency_changed(self, _idx: int) -> None:
cur = self.currency_combo.currentData() cur = self.currency_combo.currentData()
if not cur: if not cur:
@@ -206,233 +171,49 @@ class DashboardTab(QWidget):
self.ctx.save() self.ctx.save()
self.refresh() self.refresh()
def _retranslate(self) -> None: def _apply_budget(self, amount: float) -> None:
self._title.setText(tr("dashboard.title")) """Ghi giá trị ở ô nhập thành ngân sách mới — mở một chu kỳ số dư mới,
self.refresh_btn.setToolTip(tr("dashboard.refresh_tooltip")) phần đã tiêu trước thời điểm này không còn được tính."""
self.currency_lbl.setText(tr("monitoring.overview_currency"))
self.currency_combo.setToolTip(tr("dashboard.currency_tooltip"))
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
self.ai_analyze_btn.setToolTip(tr("dashboard.ai_analyze_tooltip"))
self.apply_strategy_btn.setText(tr("dashboard.strategy_btn"))
self.apply_strategy_btn.setToolTip(tr("dashboard.strategy_tooltip"))
self._habits_title.setText(tr("dashboard.habits_title"))
self._chart_title.setText(tr("dashboard.chart_title"))
self.chart_prev_btn.setToolTip(tr("dashboard.chart_prev"))
self.chart_next_btn.setToolTip(tr("dashboard.chart_next"))
self.budget_card.apply_btn.setToolTip(tr("usage.budget_apply_tooltip"))
self.budget_card.budget_spin.setToolTip(tr("usage.budget_spin_tooltip"))
self.refresh()
def _apply_budget(self) -> None:
"""Persist the spin box's value as the new budget — starts a fresh
remaining-balance window (spend before now is no longer counted)."""
ccy = (self.ctx.config.data.get("usage") or {}).get("currency", "USD") ccy = (self.ctx.config.data.get("usage") or {}).get("currency", "USD")
ut.set_budget(self.ctx.config, self.budget_card.budget_spin.value(), ccy) ut.set_budget(self.ctx.config, amount, ccy)
self.ctx.save() self.ctx.save()
self._refresh_budget() self._refresh_budget()
def _refresh_budget(self) -> None: def _refresh_budget(self) -> None:
from ..core import model_pricing as mp from ..core import model_pricing as mp
pricing = self._pricing() pricing = self._pricing()
status = ut.budget_status(self.ctx.config) self.cards.set_budget(
if status is None: ut.budget_status(self.ctx.config), pricing,
self.budget_card.set(tr("usage.budget_title"), "—", tr("usage.budget_no_budget")) lambda v, a, b: mp.convert(v, a, b, self.ctx.config))
self.budget_card.budget_spin.setValue(0.0)
return
remaining_disp = mp.convert(status["remaining_usd"], "USD",
pricing.get("currency", "USD"), self.ctx.config)
amount_disp = mp.convert(status["amount_usd"], "USD",
pricing.get("currency", "USD"), self.ctx.config)
value = (f"{ut.format_cost(status['remaining_usd'], pricing, digits=2)}"
f" / {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"])
# keep the entry field showing the CURRENT budget (in display currency) —
# only when it doesn't already have unsaved focus/edits from the user.
if not self.budget_card.budget_spin.hasFocus():
self.budget_card.budget_spin.setValue(round(amount_disp, 2))
def _period_range(self): def _retranslate(self) -> None:
"""The SELECTED period as an inclusive (start, end) date range — drives self._title.setText(tr("dashboard.title"))
the whole dashboard (cards, chart, habits).""" self.refresh_btn.setToolTip(tr("dashboard.refresh_tooltip"))
gran = self.gran_combo.currentData() or "week" self.currency_lbl.setText(tr("monitoring.overview_currency"))
start, end = ut.period_bounds(gran, self._chart_offset) self.currency_combo.setToolTip(tr("dashboard.currency_tooltip"))
return start, end - timedelta(days=1) # load_events end is inclusive self.habits_panel.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
self.habits_panel.ai_analyze_btn.setToolTip(tr("dashboard.ai_analyze_tooltip"))
def _on_gran_changed(self, *_a) -> None: self.habits_panel.apply_strategy_btn.setText(tr("dashboard.strategy_btn"))
self._chart_offset = 0 # period size changed → back to current self.habits_panel.apply_strategy_btn.setToolTip(tr("dashboard.strategy_tooltip"))
self.refresh() # the filter drives the WHOLE dashboard self.habits_panel.title.setText(tr("dashboard.habits_title"))
self.chart_panel.title.setText(tr("dashboard.chart_title"))
def _chart_prev(self) -> None: self.chart_panel.prev_btn.setToolTip(tr("dashboard.chart_prev"))
self._chart_offset -= 1 # page one period into the past self.chart_panel.next_btn.setToolTip(tr("dashboard.chart_next"))
self.budget_card.apply_btn.setToolTip(tr("usage.budget_apply_tooltip"))
self.budget_card.budget_spin.setToolTip(tr("usage.budget_spin_tooltip"))
self.refresh() self.refresh()
def _chart_next(self) -> None: # ---- nạp dữ liệu -----------------------------------------------------
self._chart_offset = min(0, self._chart_offset + 1) # never past the present
self.refresh()
@staticmethod
def _delta_txt(cur: float, prev: float) -> str:
"""▲/▼ percent change of ``cur`` vs ``prev`` (empty if no baseline)."""
if not prev:
return ""
pct = (cur - prev) / prev * 100
arrow = "▲" if pct > 0.5 else ("▼" if pct < -0.5 else "•")
return f"{arrow}{abs(pct):.0f}%"
def _refresh_chart(self, *_a) -> None:
"""Break the SELECTED period into its parts: WEEK → 7 days (Mon–Sun) ·
MONTH → weeks W1…Wn · YEAR → 12 months. Dashed lines mark the previous
week's / month's average per point with the % change of the totals."""
if not hasattr(self, "chart"):
return
gran = self.gran_combo.currentData() or "week"
metric = self.metric_combo.currentData() or "cost"
events = ut.load_events() # all events; breakdown slices by period
pricing = self._pricing()
parts = ut.period_breakdown(events, gran, pricing, offset=self._chart_offset)
mi = 0 if metric == "tokens" else 1 # (label, tokens, cost) → +1 for the value
pts = [(row[0], float(row[mi + 1])) for row in parts]
# Compact cost format (2 decimals, K/M above 1,000/1,000,000) — the
# chart's y-axis label box is narrow; format_cost's full precision (up
# to 4 decimals for USD) overflowed it, clipping/obscuring the amount.
fmt = _fmt_tokens if metric == "tokens" else (lambda v: ut.format_cost_compact(v, pricing))
# One dashed comparison line that FOLLOWS the filter: the selected period
# vs the previous SAME-granularity one — "Last week" in week view,
# "Last month" in month view, "Last year" in year view. Drawn at the
# previous period's average per point so it sits on-scale; the label shows
# the % change of the period totals.
cur = ut.period_totals(events, gran, pricing, self._chart_offset)
prev = ut.period_totals(events, gran, pricing, self._chart_offset - 1)
ref_key = {"week": "dashboard.ref_last_week",
"month": "dashboard.ref_last_month",
"year": "dashboard.ref_last_year"}.get(gran, "dashboard.ref_last_week")
n_points = max(1, len(parts))
refs = []
if prev[mi] > 0:
# Muted on purpose: the comparison line is a reference, not the
# series — it must not compete with the accent-coloured spline.
refs.append((prev[mi] / n_points,
f"{tr(ref_key)} {self._delta_txt(cur[mi], prev[mi])}",
current_palette().text_muted))
self.chart.set_reference_lines(refs)
self.chart.set_data(pts, fmt, tr(f"dashboard.metric_{metric}"))
self._chart_period_lbl.setText(ut.period_range_label(gran, self._chart_offset))
self.chart_next_btn.setEnabled(self._chart_offset < 0)
# ---- main refresh --------------------------------------------------------
def refresh(self) -> None: def refresh(self) -> None:
start, end = self._period_range() start, end = self._period_range()
events = ut.load_events(start, end) events = ut.load_events(start, end)
s = ut.summarize(events)
pricing = self._pricing() pricing = self._pricing()
costs = ut.cost_usd_events(events, pricing) # honors the per-model price table s = ut.summarize(events)
total_cost = sum(costs.values())
est_note = (tr("dashboard.estimated_note", pct=int(s["estimated_share"] * 100)) self.cards.set_usage(s, ut.cost_usd_events(events, pricing), pricing)
if s["estimated_share"] > 0 else "") self.habits_panel.set_summary(s, bool(events))
self.card_total.set(tr("dashboard.card_total"), _fmt_tokens(s["total"]), # Biểu đồ đọc TOÀN BỘ sự kiện rồi tự cắt theo khoảng — nó cần cả khoảng
tr("dashboard.card_turns", n=s["turns"])) # liền trước để vẽ đường so sánh, thứ không nằm trong (start, end).
self.card_in.set(tr("dashboard.card_in"), _fmt_tokens(s["in"]), self.chart_panel.refresh(ut.load_events(), pricing)
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(total_cost, pricing, digits=2), est_note)
# ---- habits -----------------------------------------------------------
lines: List[str] = []
if not events:
lines.append(f"<i>{tr('dashboard.no_data')}</i>")
else:
lines.append(f"<b>{tr('dashboard.h_top')}</b>")
lines.append("<ol>")
for label, tok in s["top_labels"]:
pct = int(tok * 100 / s["total"]) if s["total"] else 0
lines.append(f"<li>{label[:60]} — {_fmt_tokens(tok)} tokens ({pct}%)</li>")
lines.append("</ol>")
src_parts = ", ".join(
f"{tr(f'app.tab.{k}') if k in ('cowork', 'code') else k}: {_fmt_tokens(v)}"
for k, v in s["by_source"])
lines.append(f"<b>{tr('dashboard.h_by_source')}</b>: {src_parts}<br>")
lines.append(f"<b>{tr('dashboard.h_avg')}</b>: "
f"{_fmt_tokens(s['avg_per_turn'])} tokens<br>")
if s["busiest_day"]:
lines.append(f"<b>{tr('dashboard.h_busiest_day')}</b>: {s['busiest_day']}<br>")
if s["busiest_hour"] is not None:
lines.append(f"<b>{tr('dashboard.h_busiest_hour')}</b>: "
f"{s['busiest_hour']:02d}:00–{s['busiest_hour']:02d}:59<br>")
if s["estimated_share"] > 0:
lines.append(f"<i>{tr('dashboard.estimated_note', pct=int(s['estimated_share'] * 100))}</i>")
self.habits.setHtml("".join(lines))
self._refresh_chart()
self._refresh_budget() self._refresh_budget()
def _apply_saving_strategy(self) -> None:
"""Apply an AI-suggested cost-saving strategy AFTER the user approves:
turn on auto-compress and compress earlier (lower threshold) + compress
content before sending it to the agent — cutting tokens on every turn."""
from PySide6.QtWidgets import QMessageBox
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 # compress at 60% of the window (was ~80%)
cx["compress_before_send"] = True # digest context before each turn
self.ctx.save()
self.status_message.emit(tr("dashboard.strategy_applied"))
# ---- AI habits analysis ----------------------------------------------------
def _ai_analyze(self) -> None:
"""✨ Send the aggregated numbers (never raw prompt text) to the active
provider and show habit feedback + token-saving recommendations."""
if getattr(self, "_ai_worker", None) is not None:
return
start, end = self._period_range()
events = ut.load_events(start, end)
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())
provider = ctx.build_active_provider()
reply = provider.chat([{"role": "user", "content": prompt}],
cancel=worker.stop_event)
return {"text": (reply.get("content") or "").strip()}
def done(result: dict) -> None:
self._ai_worker = None
self.ai_analyze_btn.setEnabled(True)
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
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) # offer to apply the saving strategy
def failed(err: str) -> None:
self._ai_worker = None
self.ai_analyze_btn.setEnabled(True)
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
self.status_message.emit(str(err))
w = AgentWorker(job)
w.finished_ok.connect(done)
w.failed.connect(failed)
self._ai_worker = w
w.start()