Feature/delta team/epic r04 #7
@@ -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
@@ -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"<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.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()
|
||||
Reference in New Issue
Block a user