diff --git a/presentation/dashboard/habits_widget.py b/presentation/dashboard/habits_widget.py
new file mode 100644
index 0000000..259bf8b
--- /dev/null
+++ b/presentation/dashboard/habits_widget.py
@@ -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("%s" % tr("dashboard.no_data"))
+ self.habits.setHtml("".join(lines))
+ return
+
+ lines.append("%s" % tr("dashboard.h_top"))
+ lines.append("
")
+ for label, tok in s["top_labels"]:
+ pct = int(tok * 100 / s["total"]) if s["total"] else 0
+ lines.append("- %s — %s tokens (%d%%)
"
+ % (label[:60], _fmt_tokens(tok), pct))
+ lines.append("
")
+ 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("%s: %s
" % (tr("dashboard.h_by_source"), src_parts))
+ lines.append("%s: %s tokens
"
+ % (tr("dashboard.h_avg"), _fmt_tokens(s["avg_per_turn"])))
+ if s["busiest_day"]:
+ lines.append("%s: %s
"
+ % (tr("dashboard.h_busiest_day"), s["busiest_day"]))
+ if s["busiest_hour"] is not None:
+ h = s["busiest_hour"]
+ lines.append("%s: %02d:00–%02d:59
"
+ % (tr("dashboard.h_busiest_hour"), h, h))
+ if s["estimated_share"] > 0:
+ lines.append("%s" % 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"))
diff --git a/presentation/dashboard/token_usage_card_widget.py b/presentation/dashboard/token_usage_card_widget.py
new file mode 100644
index 0000000..7cac3ab
--- /dev/null
+++ b/presentation/dashboard/token_usage_card_widget.py
@@ -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))
diff --git a/presentation/dashboard/usage_chart_widget.py b/presentation/dashboard/usage_chart_widget.py
new file mode 100644
index 0000000..67c4e28
--- /dev/null
+++ b/presentation/dashboard/usage_chart_widget.py
@@ -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
diff --git a/ui/dashboard_tab.py b/ui/dashboard_tab.py
index 8978b8e..556daa9 100644
--- a/ui/dashboard_tab.py
+++ b/ui/dashboard_tab.py
@@ -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
-cards (total, input, output, cache tokens, and cost per bucket). Unit prices
-still come from Monitoring's model pricing table (same ``usage.*`` config keys
-— both screens always agree); the currency picker itself lives HERE, beside
-refresh. Bottom: a habits summary — which tasks/sessions burn the most tokens,
-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
-available, ~4 chars/token estimates otherwise).
+Ba mảng đã bóc sang ``presentation/dashboard/``:
+
+ token_usage_card_widget.py 5 thẻ số liệu + thẻ Ngân sách
+ usage_chart_widget.py biểu đồ theo tuần / tháng / năm
+ habits_widget.py thói quen dùng token + nhận xét của AI
+
+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ẻ,
+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 datetime import date, timedelta
-from typing import Dict, List, Optional
+from datetime import timedelta
+from typing import Dict
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtWidgets import (
- QComboBox, QGridLayout, QHBoxLayout, QLabel,
- QPushButton, QScrollArea, QTextBrowser, QVBoxLayout, QWidget,
+ QComboBox, QHBoxLayout, QLabel, QPushButton, QScrollArea, QVBoxLayout,
+ QWidget,
)
from ..core import usage_tracker as ut
-from ..core.worker import AgentWorker
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 ..theme import current_palette
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):
@@ -40,7 +38,6 @@ class DashboardTab(QWidget):
def __init__(self, ctx: AppContext):
super().__init__()
self.ctx = ctx
-
outer = QVBoxLayout(self)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
@@ -50,35 +47,37 @@ class DashboardTab(QWidget):
outer.addWidget(scroll)
root = QVBoxLayout(content)
- # ---- header: title + the PERIOD FILTER (applies to the WHOLE dashboard —
- # cards, chart and habits all follow the selected week/month) + refresh
- self._chart_offset = 0 # 0 = current period; <0 = a past period
+ self.cards = TokenUsageCardWidget()
+ self.chart_panel = UsageChartWidget()
+ self.habits_panel = HabitsWidget(ctx)
+
+ # ---- hàng tiêu đề ------------------------------------------------
head = QHBoxLayout()
self._title = QLabel()
self._title.setStyleSheet("font-weight:700; font-size:15px;")
- self.chart_prev_btn = QPushButton()
- self.chart_prev_btn.setIcon(icon("chevron-left"))
- self.chart_prev_btn.setFixedWidth(30)
- self.chart_prev_btn.clicked.connect(self._chart_prev)
- self._chart_period_lbl = QLabel()
- self._chart_period_lbl.setObjectName("hint")
- self._chart_period_lbl.setAlignment(Qt.AlignCenter)
- self._chart_period_lbl.setMinimumWidth(170)
- self.chart_next_btn = QPushButton()
- self.chart_next_btn.setIcon(icon("chevron-right"))
- self.chart_next_btn.setFixedWidth(30)
- self.chart_next_btn.clicked.connect(self._chart_next)
- self.gran_combo = QComboBox()
+ self.refresh_btn = QPushButton("")
+ self.refresh_btn.setIcon(icon("refresh"))
+ self.refresh_btn.setFixedWidth(34)
+ self.refresh_btn.clicked.connect(self.refresh)
+ head.addWidget(self._title, 1)
+ head.addWidget(self.refresh_btn)
+ root.addLayout(head)
+
+ # ---- hàng điều khiển ---------------------------------------------
+ # Hai hàng, gom theo việc control làm gì, thay vì chín widget xâu trên
+ # một dòng nơi tiêu đề, bộ lật ngày, hai ô chọn biểu đồ, ô chọn tiền tệ
+ # và nút Làm mới đọc thành một dải không phân biệt được.
+ # 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"):
- self.gran_combo.addItem(tr(f"dashboard.gran_{g}"), g)
- self.gran_combo.currentIndexChanged.connect(self._on_gran_changed)
- self.metric_combo = QComboBox()
+ self.chart_panel.gran_combo.addItem(tr("dashboard.gran_%s" % g), g)
+ self.chart_panel.gran_combo.currentIndexChanged.connect(self._on_gran_changed)
for m in ("cost", "tokens"):
- self.metric_combo.addItem(tr(f"dashboard.metric_{m}"), m)
- self.metric_combo.currentIndexChanged.connect(self._refresh_chart)
- # Display-currency picker — moved here from Monitoring's Token Usage
- # card, right beside refresh; both screens still share the same
- # usage.currency config key, so changing it here updates everywhere.
+ self.chart_panel.metric_combo.addItem(tr("dashboard.metric_%s" % m), m)
+ self.chart_panel.filter_changed.connect(self.refresh)
+
+ # Ô chọn tiền hiển thị — dời từ thẻ Token Usage của Monitoring sang đây,
+ # 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.setObjectName("hint")
self.currency_combo = QComboBox()
@@ -88,102 +87,33 @@ class DashboardTab(QWidget):
(self.ctx.config.data.get("usage") or {}).get("currency", "USD"))
self.currency_combo.setCurrentIndex(max(0, idx))
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.setSpacing(6)
- controls.addWidget(self.chart_prev_btn) # period pager
- controls.addWidget(self._chart_period_lbl)
- controls.addWidget(self.chart_next_btn)
+ controls.addWidget(self.chart_panel.prev_btn) # lật khoảng
+ controls.addWidget(self.chart_panel.period_lbl)
+ controls.addWidget(self.chart_panel.next_btn)
controls.addSpacing(12)
- controls.addWidget(self.gran_combo) # what the chart plots
- controls.addWidget(self.metric_combo)
+ controls.addWidget(self.chart_panel.gran_combo) # biểu đồ vẽ gì
+ controls.addWidget(self.chart_panel.metric_combo)
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)
root.addLayout(controls)
- # ---- stat cards ---------------------------------------------------
- # Cost is the headline this screen exists for, so it gets a card twice
- # the height of the rest instead of being the fifth of five identical
- # tiles — with six equal cards nothing said which number mattered.
- cards_grid = QGridLayout()
- cards_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()
- # 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)
+ # ---- ba mảng nội dung --------------------------------------------
+ root.addWidget(self.cards)
+ root.addWidget(self.chart_panel)
+ self.habits_panel.habits.setMinimumHeight(160)
+ self.habits_panel.ai_advice.setMinimumHeight(140)
+ self.habits_panel.ai_advice.setOpenExternalLinks(False)
+ self.habits_panel._khoang = self._period_range
+ root.addWidget(self.habits_panel, 1)
- # ---- token/cost within the selected period (spline): WEEK → 7 days
- # (Mon–Sun) · MONTH → weeks W1…Wn · YEAR → 12 months. Dashed lines
- # 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)
+ self.cards.budget_applied.connect(self._apply_budget)
+ self.habits_panel.status_message.connect(self.status_message)
- # ---- habits summary -------------------------------------------------
- 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.
+ # Tự làm mới mỗi 30 giây để con số đi theo việc đang chạy.
self._timer = QTimer(self)
self._timer.setInterval(30_000)
self._timer.timeout.connect(self.refresh)
@@ -192,12 +122,47 @@ class DashboardTab(QWidget):
on_language_changed(self._retranslate)
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:
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 {})}
+ 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:
cur = self.currency_combo.currentData()
if not cur:
@@ -206,233 +171,49 @@ class DashboardTab(QWidget):
self.ctx.save()
self.refresh()
- def _retranslate(self) -> None:
- self._title.setText(tr("dashboard.title"))
- self.refresh_btn.setToolTip(tr("dashboard.refresh_tooltip"))
- 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)."""
+ def _apply_budget(self, amount: float) -> None:
+ """Ghi giá trị ở ô nhập thành ngân sách mới — mở một chu kỳ số dư mới,
+ phần đã tiêu trước thời điểm này không còn được tính."""
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._refresh_budget()
def _refresh_budget(self) -> None:
from ..core import model_pricing as mp
pricing = self._pricing()
- status = ut.budget_status(self.ctx.config)
- 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
- 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))
+ self.cards.set_budget(
+ ut.budget_status(self.ctx.config), pricing,
+ lambda v, a, b: mp.convert(v, a, b, self.ctx.config))
- def _period_range(self):
- """The SELECTED period as an inclusive (start, end) date range — drives
- the whole dashboard (cards, chart, habits)."""
- gran = self.gran_combo.currentData() or "week"
- start, end = ut.period_bounds(gran, self._chart_offset)
- return start, end - timedelta(days=1) # load_events end is inclusive
-
- def _on_gran_changed(self, *_a) -> None:
- self._chart_offset = 0 # period size changed → back to current
- self.refresh() # the filter drives the WHOLE dashboard
-
- def _chart_prev(self) -> None:
- self._chart_offset -= 1 # page one period into the past
+ def _retranslate(self) -> None:
+ self._title.setText(tr("dashboard.title"))
+ self.refresh_btn.setToolTip(tr("dashboard.refresh_tooltip"))
+ self.currency_lbl.setText(tr("monitoring.overview_currency"))
+ self.currency_combo.setToolTip(tr("dashboard.currency_tooltip"))
+ self.habits_panel.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
+ self.habits_panel.ai_analyze_btn.setToolTip(tr("dashboard.ai_analyze_tooltip"))
+ self.habits_panel.apply_strategy_btn.setText(tr("dashboard.strategy_btn"))
+ self.habits_panel.apply_strategy_btn.setToolTip(tr("dashboard.strategy_tooltip"))
+ self.habits_panel.title.setText(tr("dashboard.habits_title"))
+ self.chart_panel.title.setText(tr("dashboard.chart_title"))
+ self.chart_panel.prev_btn.setToolTip(tr("dashboard.chart_prev"))
+ 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()
- def _chart_next(self) -> None:
- self._chart_offset = min(0, self._chart_offset + 1) # never past the present
- self.refresh()
+ # ---- nạp dữ liệu -----------------------------------------------------
- @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:
start, end = self._period_range()
events = ut.load_events(start, end)
-
- s = ut.summarize(events)
pricing = self._pricing()
- costs = ut.cost_usd_events(events, pricing) # honors the per-model price table
- total_cost = sum(costs.values())
+ s = ut.summarize(events)
- 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(total_cost, pricing, digits=2), est_note)
-
- # ---- habits -----------------------------------------------------------
- lines: List[str] = []
- if not events:
- lines.append(f"{tr('dashboard.no_data')}")
- else:
- lines.append(f"{tr('dashboard.h_top')}")
- lines.append("")
- for label, tok in s["top_labels"]:
- pct = int(tok * 100 / s["total"]) if s["total"] else 0
- lines.append(f"- {label[:60]} — {_fmt_tokens(tok)} tokens ({pct}%)
")
- lines.append("
")
- 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"{tr('dashboard.h_by_source')}: {src_parts}
")
- lines.append(f"{tr('dashboard.h_avg')}: "
- f"{_fmt_tokens(s['avg_per_turn'])} tokens
")
- if s["busiest_day"]:
- lines.append(f"{tr('dashboard.h_busiest_day')}: {s['busiest_day']}
")
- if s["busiest_hour"] is not None:
- lines.append(f"{tr('dashboard.h_busiest_hour')}: "
- f"{s['busiest_hour']:02d}:00–{s['busiest_hour']:02d}:59
")
- if s["estimated_share"] > 0:
- lines.append(f"{tr('dashboard.estimated_note', pct=int(s['estimated_share'] * 100))}")
- self.habits.setHtml("".join(lines))
- self._refresh_chart()
+ self.cards.set_usage(s, ut.cost_usd_events(events, pricing), pricing)
+ self.habits_panel.set_summary(s, bool(events))
+ # Biểu đồ đọc TOÀN BỘ sự kiện rồi tự cắt theo khoảng — nó cần cả khoảng
+ # liền trước để vẽ đường so sánh, thứ không nằm trong (start, end).
+ self.chart_panel.refresh(ut.load_events(), pricing)
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()
\ No newline at end of file