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("
  1. %s — %s tokens (%d%%)
  2. " + % (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"
  1. {label[:60]} — {_fmt_tokens(tok)} tokens ({pct}%)
  2. ") - 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